Fyerl

Fyerl 查看完整档案

杭州编辑安徽师范大学  |  计算机科学与技术 编辑有赞  |  前端工程师 编辑 fyerl.me 编辑
编辑

有赞前端大量坑位,内推私信

个人动态

Fyerl 收藏了文章 · 2019-09-29

写给前端的Docker实战教程

全文超过一万字,阅读此文章,你可能需要一杯咖啡☕️~

笔者花生PeA,百度前端汪、ACGer。个人博客 pea3nut.blog;个人资料 pea3nut.info

在保留此段前提下,随意转载。请确保此段为文章最头部

本篇文章详细而又简短的介绍了:一名完全不了解 Docker 前端程序员,将全站 Docker 化的过程。内容主要包含:

  • Docker 基本概念
  • 真实站点迁移过程:

    • 静态站点
    • Nodejs 站点(Express)
    • WordPress(PHP)
  • 一些必备技巧:开机启动、常用Shell

文章会讲解使用 Docker 过程中用到的全部技术栈(Github CI、Nginx 反向代理、docker-compose),绝不会出现“详见:http://xxx”甩链接的情况

无需再查阅其他文档,看着一篇就够了!

当前有哪些问题

手动部署成本太高

笔者维护了诸多网站,其中包含:

  • 我的简历:pea3nut.info,使用 Vuejs 构建的SPA单页应用,纯静态
  • 我的博客:pea3nut.blog,使用著名的 WordPress 搭建(PHP+Apache+MySQL)
  • 一个开源项目——Pxer:pxer.pea3nut.org,官网使用 Nodejs + Express SSR 搭建

而每次我想修改某个网站内容是十分麻烦的。拿大家熟悉的纯静态站点来说,修改过程如下:

  • 下载:从Github下载代码,然后本地npm install
  • 开发:npm run dev本地修改代码,测试
  • 编译:npm run build使用 Webpack 进行编译,产出静态资源
  • 上传:打开FTP软件,上传替换文件
  • 测试:看看网站是否在线上工作正常
  • 提交:将代码提交到 Github

哪怕我只是修改个错别字,都要十几分钟

网站太多,改动太频繁,而每次不管多小的改动都很麻烦。简直让我感觉自己在维护一个万级QPS的大型项目

某个服务挂了,我不懂 Linux 无法排查

最近我发现我的 MySQL 进程总是挂掉,导致所有依赖于 MySQL 的站点都挂了

我也不知道为什么,之前还是好好的

我尝试了重启进程、重启服务器、捞报错日志百度,均未奏效

好吧,其实我不太懂 Linux,也不太懂 MySQL,我只是想用下他们搭建 WordPress 站点。而最近总出问题,让我意识到:

我不仅要维护站点,我还要维护环境

这个对于一名前端来说太难了,装个 nvm 就已经是我的极限了。MySQL无缘无故挂掉,我根本没有能力查出个一二三四,然后解决它

我不仅要保证站点本地能跑通,还必须要部署在远程VPS稳定运行。。。

重启不行。。。那就只能重装系统了

可是,由于搭建了许多站点,VPS服务器环境相当复杂(或许这就是 MySQL 挂掉的原因),光 Apache 配置文件都几百行了。重做系统的迁移成本,光是想一想就耗光了我所有的勇气

新的技术方案——Docker

总结一下有如下问题:

  • 手动部署成本太高,改错别字都很麻烦
  • 一台服务器由于时间累积导致环境变得“脏乱差”
  • 重装系统成本太高,难以迁移

而 Docker,正是我解决所有问题SCP-500万能药

那么 Docker 是如何做的呢?

镜像与容器

Docker 中有两个重要概念。

一个是容器(Container):容器特别像一个虚拟机,容器中运行着一个完整的操作系统。可以在容器中装 Nodejs,可以执行npm install,可以做一切你当前操作系统能做的事情

另一个是镜像(Image):镜像是一个文件,它是用来创建容器的。如果你有装过 Windows 操作系统,那么 Docker 镜像特别像“Win7纯净版.rar”文件

上边就是你所需要了解的 Docker 全部基础知识。就这么简单

顺便一提,在 Docker 中,我们通常称你当前使用的真实操作系统为“宿主机”(Host)

安装 Docker

安装 Docker 在你的电脑上就像安装 VS Code 一样简单

如果你使用的是Windows电脑,需要购买支持虚拟化的版本。如Win10专业版,Win10家庭版是不行的

安装完Docker后,你可能会发现自己可以打开一个漂亮的 Docker 窗口。其实这个窗口没什么用处,通常我们都是通过CLI命令行的方式操作 Docker的,就像 Git 一样

运行 Docker

接下来我们搭建一个能够托管静态文件的 Nginx 服务器

容器运行程序,而容器哪来的呢?容器是镜像创建出来的。那镜像又是哪来的呢?

镜像是通过一个 Dockerfile 打包来的,它非常像我们前端的package.json文件

所以创建关系为:

Dockerfile: 类似于“package.json”
 |
 V
Image: 类似于“Win7纯净版.rar”
 |
 V
Container: 一个完整操作系统

创建文件

我们创建一个目录hello-docker,在目录中创建一个index.html文件,内容为:

<h1>Hello docker</h1>

然后再在目录中创建一个Dockerfile文件,内容为:

FROM nginx

COPY ./index.html /usr/share/nginx/html/index.html

EXPOSE 80

此时,你的文件结构应该是:

hello-docker
  |____index.html
  |____Dockerfile

打包镜像

文件创建好了,现在我们就可以根据Dockerfile创建镜像了!

在命令行中(Windows优先使用PowerShell)键入:

cd hello-docker/ # 进入刚刚的目录
docker image build ./ -t hello-docker:1.0.0 # 打包镜像
注意!Docker 中的选项(Options)放的位置非常有讲究,docker —help imagedocker image —help是完全不同的命令

docker image build ./ -t hello-docker:1.0.0的意思是:基于路径./(当前路径)打包一个镜像,镜像的名字是hello-docker,版本号是1.0.0。该命令会自动寻找Dockerfile来打包出一个镜像

Tips: 你可以使用docker images来查看本机已有的镜像

不出意外,你应该能得到如下输出:

Sending build context to Docker daemon  3.072kB
Step 1/3 : FROM nginx
 ---> 5a3221f0137b
Step 2/3 : COPY ./index.html /usr/share/nginx/html/index.html
 ---> 1c433edd5891
Step 3/3 : EXPOSE 80
 ---> Running in c2ff9ec2e945
Removing intermediate container c2ff9ec2e945
 ---> f6a472c1b0a0
Successfully built f6a472c1b0a0
Successfully tagged hello-docker:1.0.0

可以看到其运行了 Dockerfile 中的内容,现在我们简单拆解下:

  • FROM nginx:基于哪个镜像
  • COPY ./index.html /usr/share/nginx/html/index.html:将宿主机中的./index.html文件复制进容器里的/usr/share/nginx/html/index.html
  • EXPOSE 80:容器对外暴露80端口

运行容器

我们刚刚使用 Dockerfile 创建了一个镜像。现在有镜像了,接下来要根据镜像创建容器:

docker container create -p 2333:80 hello-docker:1.0.0
docker container start xxx # xxx 为上一条命令运行得到的结果

然后在浏览器打开127.0.0.1:2333,你应该能看到刚刚自己写的index.html内容

在上边第一个命令中,我们使用docker container create来创建基于hello-docker:1.0.0镜像的一个容器,使用-p来指定端口绑定——将容器中的80端口绑定在宿主机的2333端口。执行完该命令,会返回一个容器ID

而第二个命令,则是启动这个容器

启动后,就能通过访问本机的2333端口来达到访问容器内80端口的效果了

Tips: 你可以使用docker containers ls来查看当前运行的容器

当容器运行后,可以通过如下命令进入容器内部:

docker container exec -it xxx /bin/bash # xxx 为容器ID

原理实际上是启动了容器内的/bin/bash,此时你就可以通过bash shell与容器内交互了。就像远程连接了SSH一样

发生了什么

我们总结下都发生了什么:

  1. 写一个 Dockerfile
  2. 使用docker image build来将Dockerfile打包成镜像
  3. 使用docker container create来根据镜像创建一个容器
  4. 使用docker container start来启动一个创建好的容器

虽然很简单,但是也没有感觉到“广阔天地,大有可为,为所欲为”呢?

迁移静态站点

接下来我们实战迁移一个由 Vuejs 写的纯静态 SPA 单页站点:

我打算怎么做

在没迁移 Docker 之前,若我想更新线上网站中内容时,需要:

  1. 本地npm run build打包产出静态文件
  2. 手动通过 FTP 上传到服务器
  3. git push更新 Github 源码

稍微有点麻烦,因此我打算这样改:

  1. 执行git push
  2. 自动检测到 github 有代码更新,自动打包出一个 Docker 镜像
  3. CI 编译完成后,SSH 登录 VPS,删掉现有容器,用新镜像创建一个新容器

而这样做的好处是:

  1. 不必再手动 FTP 上传文件
  2. 当我进行修改错别字这样的简单操作时,可以免测。改完直接git push,而不必本地npm run build

Github中的CI

首先是让 Github 在我每次更新代码时打包出一个镜像

在 Github,可以有免费的 CI 资源用,它就是 Travis CI

在项目中根目录中添加.travis.yml文件,内容如下:

language: node_js
node_js:
  - "12"
services:
  - docker

before_install:
  - npm install

script:
  - npm run build
  - echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
  - docker build -t pea3nut/pea3nut-info:latest .
  - docker push pea3nut/pea3nut-info:latest

文件内容非常简单,就是使用npm run build编译静态产出后,打包一个镜像并且 push 到远程。有几点需要详细说一下:

  • 为了能够让镜像上传到服务器,你需要在hub.docker.com中注册一个账号,然后替换代码中的pea3nut/pea3nut-info:latest用户名/包名:latest即可
  • 使用 Github 登录 Travis CI 后,在左边点击+加号添加自己的 Github 仓库后,需要移步到 Setting 为项目添加DOCKER_USERNAMEDOCKER_PASSWORD环境变量。这样保证我们可以秘密的登录 Docker Hub 而不被其他人看到自己的密码。如下图

然后需要添加 Dockerfile 文件来描述如何打包 Docker 镜像。

按照.travis.yml的命令次序,在打包镜像时,npm run build已经执行过了,项目产出已经有了。不必在 Docker 容器中运行npm installnpm run build之类的,直接复制文件即可:

FROM nginx

COPY ./dist/ /usr/share/nginx/html/

EXPOSE 80
Note: 过程虽然简单但是线条很长,建议本地多测试测试再进行git push

若你编译出的静态站点也是一个 SPA 单页应用,需要增加额外的 Nginx 配置来保证请求都能打到index.html。下边是我写的vhost.nginx.conf Nginx 配置文件,将不访问文件的请求全部重定向到/index.html

server {
    listen 80;
    server_name localhost;
    location / {
        root /usr/share/nginx/html;
        index index.html index.htm;
        proxy_set_header Host $host;

        if (!-f $request_filename) {
          rewrite ^.*$ /index.html break;
        }

    }

    error_page 500 502 503 504 /50x.html;
    location = /50x.html {
        root /usr/share/nginx/html;
    }
}

然后在 Dockerfile 中新加一行,将本机的vhost.nginx.conf文件复制到容器的/etc/nginx/conf.d/pea3nut-info.conf,让 Nginx 能够读取该配置文件:

  FROM nginx

  COPY ./dist/ /usr/share/nginx/html/
+ COPY ./vhost.nginx.conf /etc/nginx/conf.d/pea3nut-info.conf

  EXPOSE 80

然后执行git push后,你可以在 Travis CI 看到 CI 的编译结果。如果编译没问题,远程实际上就有了pea3nut/pea3nut-info:latest这个镜像。本地可以试试看该镜像工作是否正常:

docker image pull pea3nut/pea3nut-info:latest
docker container create -p 8082:80 pea3nut/pea3nut-info:latest
docker container start xxx # xxx 为上一条命令执行的返回值

运行完成后,浏览器访问127.0.0.1:8082应该就能看到效果了!

然后你可以登录远程 VPS 服务器,安装 Docker,执行同样的命令。然后访问远程 VPS 服务器的公网 IP + 8082 端口号,应该能看到和本地相同的效果

Tips: 忘了如何在 VPS 上安装 Docker?在上文“安装 Docker”一节,你可能需要的是 Linux 的安装方式

curl https://get.docker.com/ > install-docker.sh # 下载安装脚本
sh install-docker.sh # 执行安装脚本

Nginx 反向代理

Note: 接下来的操作都是在你的远程 VPS 服务器上操作,并非本地电脑,或者容器中

目前我们将容器挂到了 8082 端口,但是线上不可能让用户手动输入 8082 端口进行访问。而如果将容器直接挂到 80 端口,虽然这样用户可以直接不加端口直接访问,但是如果有第二个容器,或者更多容器呢?

这时候就需要在宿主机跑一个 Nginx,由它来独占 80 端口,然后根据域名来讲请求分发给响应的容器。如下图:

这种方案叫做“反向代理”

登录VPS服务器,安装 Nginx。因为我是 Ubuntu,所以可以用apt安装。其他 Linux 发行版可以百度下安装方法,通常2行内可以搞定:

apt update # 更新软件包
apt-get install nginx # 安装 Nginx
systemctl status nginx # 查看 Nginx 状态

此时本地通过浏览器访问 VPS 的公网 IP 可用看到 Nginx 的欢迎页面

然后在 VPS 服务器的/etc/nginx/conf.d/中建立一个vhost.conf文件,配置如下内容:

server {
    listen 80;
    server_name pea3nut.info;

    location / {
        proxy_pass http://127.0.0.1:8082;
    }
}

配置的意思是,监听来自 80 端口的流量,若访问域名是pea3nut.info(替换为你自己的域名),则全部转发到http://127.0.0.1:8082

配置完成后,重启 Nginx 服务器。若是 Ubuntu 可以使用systemctl restart nginx命令,不同 Linux 发行版稍有不同

配置成功后,访问pea3nut.info会看到和VPS公网IP:8082相同的效果

更新站点

而迁移完成 Docker 后,我想改一个错别字的流程变为:

  • 本地修改完成,执行git push
  • 等待 CI 编译完成
  • 登录 VPS 服务器,执行:
docker image pull pea3nut/pea3nut-info:latest
docker container create -p 8082:80 pea3nut/pea3nut-info:latest # 得到 yyy
docker container stop xxx # xxx 为当前运行的容器ID,可用 docker container ls 查看
docker container start yyy # yyy 第二条命令返回值
命令还是有些长?我们在下面会进一步优化它

迁移 Nodejs 站点(Express)

接下来我们实战迁移一个由 Nodejs 写的 Express SSR 站点

我打算怎么做

网站使用 Ejs 模板渲染页面。在没迁移 Docker 之前,若我想更新线上网站中内容时,需要:

  1. 本地修改好 Ejs 或者其他文件
  2. 手动通过 FTP 上传到服务器
  3. 在服务器端重启 Nodejs 进程。若有 npm 包依赖改动,需要在VPS服务器上手动执行npm install
  4. git push更新 Github 源码

稍微有点麻烦,因此我打算这样改:

  1. 执行git push
  2. 自动检测到 github 有代码更新,自动打包出一个 Docker 镜像
  3. CI 编译完成后,SSH 登录 VPS,删掉现有容器,用新镜像创建一个新容器

而这样做的好处是:

  1. 不必再手动 FTP 上传文件
  2. 不必手动维护服务器的 Nodejs 运行环境

实施

具体的过程和处理静态站点没有什么特别的区别,无非是:

  1. 编写 Dockerfile 文件
  2. 在 CI 时自动打包镜像
  3. 在VPS增加一个 Nginx 反向代理

这次就不重复讲了,具体的配置可以参考项目中的相关文件

Tips: 你可能发现了 Dockerfile 中的ENTRYPOINT命令必须指定一个前台进程。若你的 Nodejs 应用是使用 PM2 进行保活的,你需要替换pm2 start app.jspm2-docker app.js

docker-compose

当将 Nodejs 站点迁移完成,我们的 VPS 服务器上已经运行了2个容器。每次镜像更新都要手动的docker container create带一堆参数是比较麻烦的,尤其是当日后容器日益增多的时候。而这时,就轮到docker-compose登场了~

docker-compose 是 Docker 官方提供的一个 Docker 管理工具。若你是通过桌面端的 Docker 安装包安装的 Docker,它是会默认为你安装 docker-compose 的。可以试试如下命令:

docker-compose --help

如果是在 Linux,可以通过如下命令安装 docker-compose:

curl -L "https://github.com/docker/compose/releases/download/1.23.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
chmode +x /usr/local/bin/docker-compose

docker-compose 和 Docker 差不多,也是只要一份文件就能跑起来。docker-compose 主要的作用就是能够让你不必手敲那么多 Docker 命令

建立一个目录,然后在目录中建立docker-compose.yml,内容如下:

version: "3.7" # 这个是配置文件的版本,不同的版本号声明方式会有细微的不同
services:
    info:
        container_name: pea3nut-info
        image: pea3nut/pea3nut-info:latest
        ports:
            - "8082:80"
        restart: on-failure

然后在目录中键入如下命令就能将服务跑起来:

docker-compose up info

docker-compose 会帮我们自动去拉镜像,创建容器,将容器中的80端口映射为宿主机的8082端口。restart字段还要求 docker-compose 当发现容器意外挂掉时重新启动容器,类似于 pm2,所以你不必再在容器内使用 pm2

如果想要更新一个镜像创建新容器,只需要:

docker-compose pull info
docker-compose stop info
docker-compose rm info
docker-compose up -d info # -d 代表后台运行
笔者已将自己网站部署方式开源,可参考github/pea3nut-hub

迁移 WordPress 站点(Apache + PHP + MySQL)

接下来我们实战迁移一个 WordPress 站点

可能你也发现了这个站点和其他站点的一个非常大的不同——他的源码和数据是不能公开的

之前我们打包镜像时,都是直接将代码打进镜像内的。这条方案用在这里显然是不行的,有两个问题:

  1. 我不想公开 MySQL 数据文件和网站内容(如图片)。若将这些打包进镜像,任何人都能docker image pull下载到镜像,然后取得镜像内的文件
  2. 当容器被删掉,存储的 MySQL 数据都将丢失

Volume

Docker 提供了一个叫做 Volume 的东西,可以将容器内和宿主机的某个文件夹进行”绑定“,任何文件改动都会得到同步。所以,我可以将整个站点目录和 MySQL 目录都挂载为 Volume。这样,当容器删除时,所有数据文件和源码都会保留。

在本地建立./blog/mysql-data目录存储 MySQL 数据,建立./blog/wordpress目录存储 WordPress 源码。然后修改docker-compose.yml如下:

version: "3.7"
services:
    info:
        container_name: pea3nut-info
        image: pea3nut/pea3nut-info:latest
        ports:
            - "8082:80"
        restart: on-failure
+   blog:
+       container_name: pea3nut-blog
+       image: tutum/lamp:latest
+       ports:
+           - "8081:80"
+       volumes:
+           - ./blog/mysql-data:/var/lib/mysql
+           - ./blog/wordpress:/app
+       restart: on-failure

可以看到这次根本没有打包镜像,而是直接使用tutum/lamp镜像提供的 LAMP 环境(Linux + Apache + MySQL + PHP),然后将 MySQL 数据目录/var/lib/mysql和源码目录/app都挂载出来就可以了

Tips: 通过 Volume 我们只是解决了部署问题,而如何本地开发然后将源码同步到服务器呢?用 FTP 当然是可以的,但是稍微有点麻烦。其实你可以自建一个 Git 服务器!详见:pea3nut.blog/e127

吭和其他技巧

源码

静态站点迁移(笔者简历):

PHP 站点迁移(笔者博客):

Nodejs 迁移(Pxer 官网):

其他:

后记

你好,这里是花生PeA。感谢你能看完这篇文章,非常感谢!

在文章撰写两个月前,我决定将站点全部迁移到 Docker。两个星期前,我决定将过程整理成一篇博客。没想到写了这么久,写了上万字

说实话笔者在撰写过程中其实是有些担心的。一方面自己真的只是一名前端,对于 Docker 的了解仅仅停留在使用方面,担心自己是否真的能“跨界”写出一篇Docker教程;另一方面随着文章字数止不住的上升,也十分担心在当今的网络环境下,是否真的有人愿意花时间读一篇上万字的技术文章

但是 Docker 真的很好用。全站 Docker 化后,当我再次迁移服务器时,我发现我竟可以在十行命令内完成整个环境的迁移,耗时十分钟!这种“爽快”的感觉也是我撰写文章的动力——我想将这份爽快分享给屏幕前的你。希望你也能喜欢上 Docker ~ ❤️

查看原文

Fyerl 收藏了文章 · 2019-09-12

如何实现Web页面录屏?

摘要: 很有意思的操作...

Fundebug经授权转载,版权归原作者所有。

写在前面的话

在看到评论后,突然意识到自己没有提前说明,本文可以说是一篇调研学习文,是我自己感觉可行的一套方案,后续会去读读已经开源的一些类似的代码库,补足自己遗漏的一些细节,所以大家可以当作学习文,生产环境慎用。

录屏重现错误场景

如果你的应用有接入到web apm系统中,那么你可能就知道apm系统能帮你捕获到页面发生的未捕获错误,给出错误栈,帮助你定位到BUG。但是,有些时候,当你不知道用户的具体操作时,是没有办法重现这个错误的,这时候,如果有操作录屏,你就可以清楚地了解到用户的操作路径,从而复现这个BUG并且修复。

实现思路

思路一:利用Canvas截图

这个思路比较简单,就是利用canvas去画网页内容,比较有名的库有:html2canvas,这个库的简单原理是:

  1. 收集所有的DOM,存入一个queue中;
  2. 根据zIndex按照顺序将DOM一个个通过一定规则,把DOM和其CSS样式一起画到Canvas上。

这个实现是比较复杂的,但是我们可以直接使用,所以我们可以获取到我们想要的网页截图。

为了使得生成的视频较为流畅,我们一秒中需要生成大约25帧,也就是需要25张截图,思路流程图如下:

但是,这个思路有个最致命的不足:为了视频流畅,一秒中我们需要25张图,一张图300KB,当我们需要30秒的视频时,图的大小总共为220M,这么大的网络开销明显不行。

思路二:记录所有操作重现

为了降低网络开销,我们换个思路,我们在最开始的页面基础上,记录下一步步操作,在我们需要"播放"的时候,按照顺序应用这些操作,这样我们就能看到页面的变化了。这个思路把鼠标操作和DOM变化分开:

鼠标变化:

  1. 监听mouseover事件,记录鼠标的clientX和clientY。
  2. 重放的时候使用js画出一个假的鼠标,根据坐标记录来更改"鼠标"的位置。

DOM变化:

  1. 对页面DOM进行一次全量快照。包括样式的收集、JS脚本去除,并通过一定的规则给当前的每个DOM元素标记一个id。
  2. 监听所有可能对界面产生影响的事件,例如各类鼠标事件、输入事件、滚动事件、缩放事件等等,每个事件都记录参数和目标元素,目标元素可以是刚才记录的id,这样的每一次变化事件可以记录为一次增量的快照。
  3. 将一定量的快照发送给后端。
  4. 在后台根据快照和操作链进行播放。

当然这个说明是比较简略的,鼠标的记录比较简单,我们不展开讲,主要说明一下DOM监控的实现思路。

页面首次全量快照

首先你可能会想到,要实现页面全量快照,可以直接使用outerHTML

const content = document.documentElement.outerHTML;

这样就简单记录了页面的所有DOM,你只需要首先给DOM增加标记id,然后得到outerHTML,然后去除JS脚本。

但是,这里有个问题,使用outerHTML记录的DOM会将把临近的两个TextNode合并为一个节点,而我们后续监控DOM变化时会使用MutationObserver,此时你需要大量的处理来兼容这种TextNode的合并,不然你在还原操作的时候无法定位到操作的目标节点。

那么,我们有办法保持页面DOM的原有结构吗?

答案是肯定的,在这里我们使用Virtual DOM来记录DOM结构,把documentElement变成Virtual DOM,记录下来,后面还原的时候重新生成DOM即可。

DOM转化为Virtual DOM

我们在这里只需要关心两种Node类型:Node.TEXT_NODENode.ELEMENT_NODE。同时,要注意,SVG和SVG子元素的创建需要使用API:createElementNS,所以,我们在记录Virtual DOM的时候,需要注意namespace的记录,上代码:

const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
const XML_NAMESPACES = ['xmlns', 'xmlns:svg', 'xmlns:xlink'];

function createVirtualDom(element, isSVG = false)  {
  switch (element.nodeType) {
    case Node.TEXT_NODE:
      return createVirtualText(element);
    case Node.ELEMENT_NODE:
      return createVirtualElement(element, isSVG || element.tagName.toLowerCase() === 'svg');
    default:
      return null;
  }
}

function createVirtualText(element) {
  const vText = {
    text: element.nodeValue,
    type: 'VirtualText',
  };
  if (typeof element.__flow !== 'undefined') {
    vText.__flow = element.__flow;
  }
  return vText;
}

function createVirtualElement(element, isSVG = false) {
  const tagName = element.tagName.toLowerCase();
  const children = getNodeChildren(element, isSVG);
  const { attr, namespace } = getNodeAttributes(element, isSVG);
  const vElement = {
    tagName, type: 'VirtualElement', children, attributes: attr, namespace,
  };
  if (typeof element.__flow !== 'undefined') {
    vElement.__flow = element.__flow;
  }
  return vElement;
}

function getNodeChildren(element, isSVG = false) {
  const childNodes = element.childNodes ? [...element.childNodes] : [];
  const children = [];
  childNodes.forEach((cnode) => {
    children.push(createVirtualDom(cnode, isSVG));
  });
  return children.filter(c => !!c);
}

function getNodeAttributes(element, isSVG = false) {
  const attributes = element.attributes ? [...element.attributes] : [];
  const attr = {};
  let namespace;
  attributes.forEach(({ nodeName, nodeValue }) => {
    attr[nodeName] = nodeValue;
    if (XML_NAMESPACES.includes(nodeName)) {
      namespace = nodeValue;
    } else if (isSVG) {
      namespace = SVG_NAMESPACE;
    }
  });
  return { attr, namespace };
}

通过以上代码,我们可以将整个documentElement转化为Virtual DOM,其中__flow用来记录一些参数,包括标记ID等,Virtual Node记录了:type、attributes、children、namespace。

Virtual DOM还原为DOM

将Virtual DOM还原为DOM的时候就比较简单了,只需要递归创建DOM即可,其中nodeFilter是为了过滤script元素,因为我们不需要JS脚本的执行。

function createElement(vdom, nodeFilter = () => true) {
  let node;
  if (vdom.type === 'VirtualText') {
    node = document.createTextNode(vdom.text);
  } else {
    node = typeof vdom.namespace === 'undefined'
      ? document.createElement(vdom.tagName)
      : document.createElementNS(vdom.namespace, vdom.tagName);
    for (let name in vdom.attributes) {
      node.setAttribute(name, vdom.attributes[name]);
    }
    vdom.children.forEach((cnode) => {
      const childNode = createElement(cnode, nodeFilter);
      if (childNode && nodeFilter(childNode)) {
        node.appendChild(childNode);
      }
    });
  }
  if (vdom.__flow) {
    node.__flow = vdom.__flow;
  }
  return node;
}

DOM结构变化监控

在这里,我们使用了API:MutationObserver,更值得高兴的是,这个API是所有浏览器都兼容的,所以我们可以大胆使用。

使用MutationObserver:

const options = {
  childList: true, // 是否观察子节点的变动
  subtree: true, // 是否观察所有后代节点的变动
  attributes: true, // 是否观察属性的变动
  attributeOldValue: true, // 是否观察属性的变动的旧值
  characterData: true, // 是否节点内容或节点文本的变动
  characterDataOldValue: true, // 是否节点内容或节点文本的变动的旧值
  // attributeFilter: ['class', 'src'] 不在此数组中的属性变化时将被忽略
};

const observer = new MutationObserver((mutationList) => {
    // mutationList: array of mutation
});
observer.observe(document.documentElement, options);

使用起来很简单,你只需要指定一个根节点和需要监控的一些选项,那么当DOM变化时,在callback函数中就会有一个mutationList,这是一个DOM的变化列表,其中mutation的结构大概为:

{
    type: 'childList', // or characterData、attributes
    target: <DOM>,
    // other params
}

我们使用一个数组来存放mutation,具体的callback为:

const onMutationChange = (mutationsList) => {
  const getFlowId = (node) => {
    if (node) {
      // 新插入的DOM没有标记,所以这里需要兼容
      if (!node.__flow) node.__flow = { id: uuid() };
      return node.__flow.id;
    }
  };
  mutationsList.forEach((mutation) => {
    const { target, type, attributeName } = mutation;
    const record = { 
      type, 
      target: getFlowId(target), 
    };
    switch (type) {
      case 'characterData':
        record.value = target.nodeValue;
        break;
      case 'attributes':
        record.attributeName = attributeName;
        record.attributeValue = target.getAttribute(attributeName);
        break;
      case 'childList':
        record.removedNodes = [...mutation.removedNodes].map(n => getFlowId(n));
        record.addedNodes = [...mutation.addedNodes].map((n) => {
          const snapshot = this.takeSnapshot(n);
          return {
            ...snapshot,
            nextSibling: getFlowId(n.nextSibling),
            previousSibling: getFlowId(n.previousSibling)
          };
        });
        break;
    }
    this.records.push(record);
  });
}

function takeSnapshot(node, options = {}) {
  this.markNodes(node);
  const snapshot = {
    vdom: createVirtualDom(node),
  };
  if (options.doctype === true) {
    snapshot.doctype = document.doctype.name;
    snapshot.clientWidth = document.body.clientWidth;
    snapshot.clientHeight = document.body.clientHeight;
  }
  return snapshot;
}

这里面只需要注意,当你处理新增DOM的时候,你需要一次增量的快照,这里仍然使用Virtual DOM来记录,在后面播放的时候,仍然生成DOM,插入到父元素即可,所以这里需要参照DOM,也就是兄弟节点。

表单元素监控

上面的MutationObserver并不能监控到input等元素的值变化,所以我们需要对表单元素的值进行特殊处理。

oninput事件监听

MDN文档:developer.mozilla.org/en-US/docs/…

事件对象:select、input,textarea

window.addEventListener('input', this.onFormInput, true);

onFormInput = (event) => {
  const target = event.target;
  if (
    target && 
    target.__flow &&
    ['select', 'textarea', 'input'].includes(target.tagName.toLowerCase())
   ) {
     this.records.push({
       type: 'input', 
       target: target.__flow.id, 
       value: target.value, 
     });
   }
}

在window上使用捕获来捕获事件,后面也是这样处理的,这样做的原因是我们是可能并经常在冒泡阶段阻止冒泡来实现一些功能,所以使用捕获可以减少事件丢失,另外,像scroll事件是不会冒泡的,必须使用捕获。

onchange事件监听

MDN文档:developer.mozilla.org/en-US/docs/…

input事件没法满足type为checkbox和radio的监控,所以需要借助onchange事件来监控

window.addEventListener('change', this.onFormChange, true);

onFormChange = (event) => {
  const target = event.target;
  if (target && target.__flow) {
    if (
      target.tagName.toLowerCase() === 'input' &&
      ['checkbox', 'radio'].includes(target.getAttribute('type'))
    ) {
      this.records.push({
        type: 'checked', 
        target: target.__flow.id, 
        checked: target.checked,
      });
    }
  }
}

onfocus事件监听

MDN文档:developer.mozilla.org/en-US/docs/…

window.addEventListener('focus', this.onFormFocus, true);

onFormFocus = (event) => {
  const target = event.target;
  if (target && target.__flow) {
    this.records.push({
      type: 'focus', 
      target: target.__flow.id,
    });
  }
}

onblur事件监听

MDN文档:developer.mozilla.org/en-US/docs/…

window.addEventListener('blur', this.onFormBlur, true);

onFormBlur = (event) => {
  const target = event.target;
  if (target && target.__flow) {
    this.records.push({
      type: 'blur', 
      target: target.__flow.id,
    });
  }
}

媒体元素变化监听

这里指audio和video,类似上面的表单元素,可以监听onplay、onpause事件、timeupdate、volumechange等等事件,然后存入records

Canvas画布变化监听

canvas内容变化没有抛出事件,所以我们可以:

  1. 收集canvas元素,定时去更新实时内容
  2. hack一些画画的API,来抛出事件

canvas监听研究没有很深入,需要进一步深入研究

播放

思路比较简单,就是从后端拿到一些信息:

  • 全量快照Virtual DOM
  • 操作链records
  • 屏幕分辨率
  • doctype

利用这些信息,你就可以首先生成页面DOM,其中包括过滤script标签,然后创建iframe,append到一个容器中,其中使用一个map来存储DOM

function play(options = {}) {
  const { container, records = [], snapshot ={} } = options;
  const { vdom, doctype, clientHeight, clientWidth } = snapshot;
  this.nodeCache = {};
  this.records = records;
  this.container = container;
  this.snapshot = snapshot;
  this.iframe = document.createElement('iframe');
  const documentElement = createElement(vdom, (node) => {
    // 缓存DOM
    const flowId = node.__flow && node.__flow.id;
    if (flowId) {
      this.nodeCache[flowId] = node;
    }
    // 过滤script
    return !(node.nodeType === Node.ELEMENT_NODE && node.tagName.toLowerCase() === 'script'); 
  });
    
  this.iframe.style.width = `${clientWidth}px`;
  this.iframe.style.height = `${clientHeight}px`;
  container.appendChild(iframe);
  const doc = iframe.contentDocument;
  this.iframeDocument = doc;
  doc.open();
  doc.write(`<!doctype ${doctype}><html><head></head><body></body></html>`);
  doc.close();
  doc.replaceChild(documentElement, doc.documentElement);
  this.execRecords();
}

function execRecords(preDuration = 0) {
  const record = this.records.shift();
  let node;
  if (record) {
    setTimeout(() => {
      switch (record.type) {
        // 'childList'、'characterData'、
        // 'attributes'、'input'、'checked'、
        // 'focus'、'blur'、'play''pause'等事件的处理
      }
      this.execRecords(record.duration);
    }, record.duration - preDuration)
  }
}

上面的duration在上文中省略了,这个你可以根据自己的优化来做播放的流畅度,看是多个record作为一帧还是原本呈现。

关于Fundebug

Fundebug专注于JavaScript、微信小程序、微信小游戏、支付宝小程序、React Native、Node.js和Java线上应用实时BUG监控。 自从2016年双十一正式上线,Fundebug累计处理了20亿+错误事件,付费客户有阳光保险、核桃编程、荔枝FM、掌门1对1、微脉、青团社等众多品牌企业。欢迎大家免费试用

查看原文

Fyerl 赞了文章 · 2019-09-12

如何实现Web页面录屏?

摘要: 很有意思的操作...

Fundebug经授权转载,版权归原作者所有。

写在前面的话

在看到评论后,突然意识到自己没有提前说明,本文可以说是一篇调研学习文,是我自己感觉可行的一套方案,后续会去读读已经开源的一些类似的代码库,补足自己遗漏的一些细节,所以大家可以当作学习文,生产环境慎用。

录屏重现错误场景

如果你的应用有接入到web apm系统中,那么你可能就知道apm系统能帮你捕获到页面发生的未捕获错误,给出错误栈,帮助你定位到BUG。但是,有些时候,当你不知道用户的具体操作时,是没有办法重现这个错误的,这时候,如果有操作录屏,你就可以清楚地了解到用户的操作路径,从而复现这个BUG并且修复。

实现思路

思路一:利用Canvas截图

这个思路比较简单,就是利用canvas去画网页内容,比较有名的库有:html2canvas,这个库的简单原理是:

  1. 收集所有的DOM,存入一个queue中;
  2. 根据zIndex按照顺序将DOM一个个通过一定规则,把DOM和其CSS样式一起画到Canvas上。

这个实现是比较复杂的,但是我们可以直接使用,所以我们可以获取到我们想要的网页截图。

为了使得生成的视频较为流畅,我们一秒中需要生成大约25帧,也就是需要25张截图,思路流程图如下:

但是,这个思路有个最致命的不足:为了视频流畅,一秒中我们需要25张图,一张图300KB,当我们需要30秒的视频时,图的大小总共为220M,这么大的网络开销明显不行。

思路二:记录所有操作重现

为了降低网络开销,我们换个思路,我们在最开始的页面基础上,记录下一步步操作,在我们需要"播放"的时候,按照顺序应用这些操作,这样我们就能看到页面的变化了。这个思路把鼠标操作和DOM变化分开:

鼠标变化:

  1. 监听mouseover事件,记录鼠标的clientX和clientY。
  2. 重放的时候使用js画出一个假的鼠标,根据坐标记录来更改"鼠标"的位置。

DOM变化:

  1. 对页面DOM进行一次全量快照。包括样式的收集、JS脚本去除,并通过一定的规则给当前的每个DOM元素标记一个id。
  2. 监听所有可能对界面产生影响的事件,例如各类鼠标事件、输入事件、滚动事件、缩放事件等等,每个事件都记录参数和目标元素,目标元素可以是刚才记录的id,这样的每一次变化事件可以记录为一次增量的快照。
  3. 将一定量的快照发送给后端。
  4. 在后台根据快照和操作链进行播放。

当然这个说明是比较简略的,鼠标的记录比较简单,我们不展开讲,主要说明一下DOM监控的实现思路。

页面首次全量快照

首先你可能会想到,要实现页面全量快照,可以直接使用outerHTML

const content = document.documentElement.outerHTML;

这样就简单记录了页面的所有DOM,你只需要首先给DOM增加标记id,然后得到outerHTML,然后去除JS脚本。

但是,这里有个问题,使用outerHTML记录的DOM会将把临近的两个TextNode合并为一个节点,而我们后续监控DOM变化时会使用MutationObserver,此时你需要大量的处理来兼容这种TextNode的合并,不然你在还原操作的时候无法定位到操作的目标节点。

那么,我们有办法保持页面DOM的原有结构吗?

答案是肯定的,在这里我们使用Virtual DOM来记录DOM结构,把documentElement变成Virtual DOM,记录下来,后面还原的时候重新生成DOM即可。

DOM转化为Virtual DOM

我们在这里只需要关心两种Node类型:Node.TEXT_NODENode.ELEMENT_NODE。同时,要注意,SVG和SVG子元素的创建需要使用API:createElementNS,所以,我们在记录Virtual DOM的时候,需要注意namespace的记录,上代码:

const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
const XML_NAMESPACES = ['xmlns', 'xmlns:svg', 'xmlns:xlink'];

function createVirtualDom(element, isSVG = false)  {
  switch (element.nodeType) {
    case Node.TEXT_NODE:
      return createVirtualText(element);
    case Node.ELEMENT_NODE:
      return createVirtualElement(element, isSVG || element.tagName.toLowerCase() === 'svg');
    default:
      return null;
  }
}

function createVirtualText(element) {
  const vText = {
    text: element.nodeValue,
    type: 'VirtualText',
  };
  if (typeof element.__flow !== 'undefined') {
    vText.__flow = element.__flow;
  }
  return vText;
}

function createVirtualElement(element, isSVG = false) {
  const tagName = element.tagName.toLowerCase();
  const children = getNodeChildren(element, isSVG);
  const { attr, namespace } = getNodeAttributes(element, isSVG);
  const vElement = {
    tagName, type: 'VirtualElement', children, attributes: attr, namespace,
  };
  if (typeof element.__flow !== 'undefined') {
    vElement.__flow = element.__flow;
  }
  return vElement;
}

function getNodeChildren(element, isSVG = false) {
  const childNodes = element.childNodes ? [...element.childNodes] : [];
  const children = [];
  childNodes.forEach((cnode) => {
    children.push(createVirtualDom(cnode, isSVG));
  });
  return children.filter(c => !!c);
}

function getNodeAttributes(element, isSVG = false) {
  const attributes = element.attributes ? [...element.attributes] : [];
  const attr = {};
  let namespace;
  attributes.forEach(({ nodeName, nodeValue }) => {
    attr[nodeName] = nodeValue;
    if (XML_NAMESPACES.includes(nodeName)) {
      namespace = nodeValue;
    } else if (isSVG) {
      namespace = SVG_NAMESPACE;
    }
  });
  return { attr, namespace };
}

通过以上代码,我们可以将整个documentElement转化为Virtual DOM,其中__flow用来记录一些参数,包括标记ID等,Virtual Node记录了:type、attributes、children、namespace。

Virtual DOM还原为DOM

将Virtual DOM还原为DOM的时候就比较简单了,只需要递归创建DOM即可,其中nodeFilter是为了过滤script元素,因为我们不需要JS脚本的执行。

function createElement(vdom, nodeFilter = () => true) {
  let node;
  if (vdom.type === 'VirtualText') {
    node = document.createTextNode(vdom.text);
  } else {
    node = typeof vdom.namespace === 'undefined'
      ? document.createElement(vdom.tagName)
      : document.createElementNS(vdom.namespace, vdom.tagName);
    for (let name in vdom.attributes) {
      node.setAttribute(name, vdom.attributes[name]);
    }
    vdom.children.forEach((cnode) => {
      const childNode = createElement(cnode, nodeFilter);
      if (childNode && nodeFilter(childNode)) {
        node.appendChild(childNode);
      }
    });
  }
  if (vdom.__flow) {
    node.__flow = vdom.__flow;
  }
  return node;
}

DOM结构变化监控

在这里,我们使用了API:MutationObserver,更值得高兴的是,这个API是所有浏览器都兼容的,所以我们可以大胆使用。

使用MutationObserver:

const options = {
  childList: true, // 是否观察子节点的变动
  subtree: true, // 是否观察所有后代节点的变动
  attributes: true, // 是否观察属性的变动
  attributeOldValue: true, // 是否观察属性的变动的旧值
  characterData: true, // 是否节点内容或节点文本的变动
  characterDataOldValue: true, // 是否节点内容或节点文本的变动的旧值
  // attributeFilter: ['class', 'src'] 不在此数组中的属性变化时将被忽略
};

const observer = new MutationObserver((mutationList) => {
    // mutationList: array of mutation
});
observer.observe(document.documentElement, options);

使用起来很简单,你只需要指定一个根节点和需要监控的一些选项,那么当DOM变化时,在callback函数中就会有一个mutationList,这是一个DOM的变化列表,其中mutation的结构大概为:

{
    type: 'childList', // or characterData、attributes
    target: <DOM>,
    // other params
}

我们使用一个数组来存放mutation,具体的callback为:

const onMutationChange = (mutationsList) => {
  const getFlowId = (node) => {
    if (node) {
      // 新插入的DOM没有标记,所以这里需要兼容
      if (!node.__flow) node.__flow = { id: uuid() };
      return node.__flow.id;
    }
  };
  mutationsList.forEach((mutation) => {
    const { target, type, attributeName } = mutation;
    const record = { 
      type, 
      target: getFlowId(target), 
    };
    switch (type) {
      case 'characterData':
        record.value = target.nodeValue;
        break;
      case 'attributes':
        record.attributeName = attributeName;
        record.attributeValue = target.getAttribute(attributeName);
        break;
      case 'childList':
        record.removedNodes = [...mutation.removedNodes].map(n => getFlowId(n));
        record.addedNodes = [...mutation.addedNodes].map((n) => {
          const snapshot = this.takeSnapshot(n);
          return {
            ...snapshot,
            nextSibling: getFlowId(n.nextSibling),
            previousSibling: getFlowId(n.previousSibling)
          };
        });
        break;
    }
    this.records.push(record);
  });
}

function takeSnapshot(node, options = {}) {
  this.markNodes(node);
  const snapshot = {
    vdom: createVirtualDom(node),
  };
  if (options.doctype === true) {
    snapshot.doctype = document.doctype.name;
    snapshot.clientWidth = document.body.clientWidth;
    snapshot.clientHeight = document.body.clientHeight;
  }
  return snapshot;
}

这里面只需要注意,当你处理新增DOM的时候,你需要一次增量的快照,这里仍然使用Virtual DOM来记录,在后面播放的时候,仍然生成DOM,插入到父元素即可,所以这里需要参照DOM,也就是兄弟节点。

表单元素监控

上面的MutationObserver并不能监控到input等元素的值变化,所以我们需要对表单元素的值进行特殊处理。

oninput事件监听

MDN文档:developer.mozilla.org/en-US/docs/…

事件对象:select、input,textarea

window.addEventListener('input', this.onFormInput, true);

onFormInput = (event) => {
  const target = event.target;
  if (
    target && 
    target.__flow &&
    ['select', 'textarea', 'input'].includes(target.tagName.toLowerCase())
   ) {
     this.records.push({
       type: 'input', 
       target: target.__flow.id, 
       value: target.value, 
     });
   }
}

在window上使用捕获来捕获事件,后面也是这样处理的,这样做的原因是我们是可能并经常在冒泡阶段阻止冒泡来实现一些功能,所以使用捕获可以减少事件丢失,另外,像scroll事件是不会冒泡的,必须使用捕获。

onchange事件监听

MDN文档:developer.mozilla.org/en-US/docs/…

input事件没法满足type为checkbox和radio的监控,所以需要借助onchange事件来监控

window.addEventListener('change', this.onFormChange, true);

onFormChange = (event) => {
  const target = event.target;
  if (target && target.__flow) {
    if (
      target.tagName.toLowerCase() === 'input' &&
      ['checkbox', 'radio'].includes(target.getAttribute('type'))
    ) {
      this.records.push({
        type: 'checked', 
        target: target.__flow.id, 
        checked: target.checked,
      });
    }
  }
}

onfocus事件监听

MDN文档:developer.mozilla.org/en-US/docs/…

window.addEventListener('focus', this.onFormFocus, true);

onFormFocus = (event) => {
  const target = event.target;
  if (target && target.__flow) {
    this.records.push({
      type: 'focus', 
      target: target.__flow.id,
    });
  }
}

onblur事件监听

MDN文档:developer.mozilla.org/en-US/docs/…

window.addEventListener('blur', this.onFormBlur, true);

onFormBlur = (event) => {
  const target = event.target;
  if (target && target.__flow) {
    this.records.push({
      type: 'blur', 
      target: target.__flow.id,
    });
  }
}

媒体元素变化监听

这里指audio和video,类似上面的表单元素,可以监听onplay、onpause事件、timeupdate、volumechange等等事件,然后存入records

Canvas画布变化监听

canvas内容变化没有抛出事件,所以我们可以:

  1. 收集canvas元素,定时去更新实时内容
  2. hack一些画画的API,来抛出事件

canvas监听研究没有很深入,需要进一步深入研究

播放

思路比较简单,就是从后端拿到一些信息:

  • 全量快照Virtual DOM
  • 操作链records
  • 屏幕分辨率
  • doctype

利用这些信息,你就可以首先生成页面DOM,其中包括过滤script标签,然后创建iframe,append到一个容器中,其中使用一个map来存储DOM

function play(options = {}) {
  const { container, records = [], snapshot ={} } = options;
  const { vdom, doctype, clientHeight, clientWidth } = snapshot;
  this.nodeCache = {};
  this.records = records;
  this.container = container;
  this.snapshot = snapshot;
  this.iframe = document.createElement('iframe');
  const documentElement = createElement(vdom, (node) => {
    // 缓存DOM
    const flowId = node.__flow && node.__flow.id;
    if (flowId) {
      this.nodeCache[flowId] = node;
    }
    // 过滤script
    return !(node.nodeType === Node.ELEMENT_NODE && node.tagName.toLowerCase() === 'script'); 
  });
    
  this.iframe.style.width = `${clientWidth}px`;
  this.iframe.style.height = `${clientHeight}px`;
  container.appendChild(iframe);
  const doc = iframe.contentDocument;
  this.iframeDocument = doc;
  doc.open();
  doc.write(`<!doctype ${doctype}><html><head></head><body></body></html>`);
  doc.close();
  doc.replaceChild(documentElement, doc.documentElement);
  this.execRecords();
}

function execRecords(preDuration = 0) {
  const record = this.records.shift();
  let node;
  if (record) {
    setTimeout(() => {
      switch (record.type) {
        // 'childList'、'characterData'、
        // 'attributes'、'input'、'checked'、
        // 'focus'、'blur'、'play''pause'等事件的处理
      }
      this.execRecords(record.duration);
    }, record.duration - preDuration)
  }
}

上面的duration在上文中省略了,这个你可以根据自己的优化来做播放的流畅度,看是多个record作为一帧还是原本呈现。

关于Fundebug

Fundebug专注于JavaScript、微信小程序、微信小游戏、支付宝小程序、React Native、Node.js和Java线上应用实时BUG监控。 自从2016年双十一正式上线,Fundebug累计处理了20亿+错误事件,付费客户有阳光保险、核桃编程、荔枝FM、掌门1对1、微脉、青团社等众多品牌企业。欢迎大家免费试用

查看原文

赞 11 收藏 9 评论 0

Fyerl 关注了专栏 · 2019-08-29

Fundebug

Fundebug是专业的应用BUG监控平台

关注 313

Fyerl 评论了文章 · 2019-04-19

企业该如何提升自身技术品牌影响力?看看腾讯、美团、有赞是怎么做的

对于技术驱动型就企业来说,“保持技术持续领先”一直是企业发展战略的重中之重。但技术型企业往往低调务实,“如何塑造和传播企业的技术品牌”成为了一个重要的课题。企业申请软件著作权、专利技术、国家高新技术企业都是塑造技术品牌的手段。一些大厂近年来还设置了“技术品牌传播经理”等职位。

图片描述

“保持技术持续领先”也是需要人干出来的,技术品牌的塑造,不仅有助于提升企业在公众心目中的公信力,更有助于持续招揽、“选、用、育、留”有创新精神的工程师,增强现有技术团队的荣誉感,组建更优质的技术团队。

开发团队的招聘一直是各个团队中最让 HR 小姐姐头疼的问题之一,前不久还有 HR 小姐姐以事件营销“钓鱼招聘”走红网络,当然我们并不鼓励这种刻意恶搞式的技术营销。

图片描述

前不久 StackOverflow 刚刚发布了一年一度的开发者调查报告,作为全世界最权威的报告今年共吸引了近 90,000 名开发人员参与调查。数据显示,开发者在选择工作时最优先考虑的因素是语言、框架等技术相关问题,其次是企业文化。

图片描述

可见向开发者人群展示企业的技术实力、技术框架和“工程师文化”等技术品牌对于招聘优秀的开发者至关重要。

覆盖开发者人群,精准推广企业技术品牌,他们是这样做的——

图片描述

腾讯新闻前端团队开设品牌专栏,团队成员集体入驻,为专栏贡献优质技术文章。在分享技术、展示团队技术实力的同时,他们还在文章下加入了团队介绍和招聘信息,SegmentFault 则为腾讯新闻前端团队给予了流量支持。

腾讯新闻前端团队历史热门文章:

《PNG图片压缩原理解析--屌丝的眼泪》
《腾讯新闻前端团队:深入理解前端性能监控》

同时在 SegmentFault 开设专栏的技术团队还有阿里云云栖社区、蚂蚁金服科技、腾讯开源、腾讯动漫前端团队、京东云技术团队、美团技术团队、有赞技术、个推技术团队、PingCap、滴滴开源 CMLJS 官方社区、知道创宇前端团队、Swoole 开源项目、Flutter 中文社区等。

我们诚挚地邀请更多企业的技术团队来 SegmentFault 社区开通技术专栏,一起发布优质技术和开源项目的最新进展,我们会对优质的项目进行推荐,持续地给予流量支持。

图片描述

SegmentFault 社区于 2012 年 6 月上线,以技术问答、专栏(技术号)、讲堂培训为核心产品形态,拥有 Web 端社区及 iOS、Android 客户端。自社区上线以来在广大开发者群体中享有极高的口碑及影响力,拥有 300 万注册用户,每月超过千万的开发者访问流量,Alexa 国内网站排名 90 位

开发者技术问答板块国内排名第一,已经帮助开发者解决超过 30 万个技术问题,每日增长超过 1000 条问答数据。用户原创产生超过 10 万篇优质技术文章。

除技术专栏外,针对企业技术创新咨询我们还有一整套完整的企业服务,包括但不限于:技术咨询、企业产品技术内训、技术品牌推广、广告服务、会务服务、人才服务等,详情请咨询bd@sifou.com,或访问https://business.segmentfault...

欢迎大家访问和关注各企业技术团队在SegmentFault 开设的专栏(不完全统计,将持续更新)———

查看原文

Fyerl 赞了文章 · 2019-03-14

web 埋点实现原理了解一下

前言

埋点,是网站分析的一种常用的数据采集方法。我们主要用来采集用户行为数据(例如页面访问路径,点击了什么元素)进行数据分析,从而让运营同学更加合理的安排运营计划。现在市面上有很多第三方埋点服务商,百度统计,友盟,growingIO 等大家应该都不太陌生,大多情况下大家都只是使用,最近我研究了下 web 埋点,你要不要了解下。

现有埋点三大类型

用户行为分析是一个大系统,一个典型的数据平台。由用户数据采集,用户行为建模分析,可视化报表展示几个模块构成。现有的埋点采集方案可以大致被分为三种,手动埋点,可视化埋点,无埋点
  1. 手动埋点
    手动代码埋点比较常见,需要调用埋点的业务方在需要采集数据的地方调用埋点的方法。优点是流量可控,业务方可以根据需要在任意地点任意场景进行数据采集,采集信息也完全由业务方来控制。这样的有点也带来了一些弊端,需要业务方来写死方法,如果采集方案变了,业务方也需要重新修改代码,重新发布。
  2. 可视化埋点
    可是化埋点是近今年的埋点趋势,很多大厂自己的数据埋点部门也都开始做这块。优点是业务方工作量少,缺点则是技术上推广和实现起来有点难(业务方前端代码规范是个大前提)。阿里的活动页很多都是运营通过可视化的界面拖拽配置实现,这些活动控件元素都带有唯一标识。通过埋点配置后台,将元素与要采集事件关联起来,可以自动生成埋点代码嵌入到页面中。
  3. 无埋点
    无埋点则是前端自动采集全部事件,上报埋点数据,由后端来过滤和计算出有用的数据,优点是前端只要加载埋点脚本。缺点是流量和采集的数据过于庞大,服务器性能压力山大,主流的 GrowingIO 就是这种实现方案。

我们暂时放弃可视化埋点的实现,在 手动埋点无埋点 上进行了尝试,为了便于描述,下文我会称采集脚本为 SDK。

思考几个问题

埋点开发需要考虑很多内容,贯穿着不轻易动手写代码的原则,我们在开发前先思考下面这几个问题
  1. 我们要采集什么内容,进行哪些采集接口的约定
  2. 业务方通过什么方式来调用我们的采集脚本
  3. 手动埋点:SDK 需要封装一个方法给业务方进行调用,传参方式业务方可控
  4. 无埋点:考虑到数据量对于服务器的压力,我们需要对无埋点进行开关配置,可以配置进行哪些元素进行无埋点采集
  5. 用户标识:游客用户和登录用户的采集数据怎么进行区分关联
  6. 设备Id:用户通过浏览器来访问 web 页面,设备Id需要存储在浏览器上,同一个用户访问不同的业务方网站,设备Id要保持一样,怎么实现
  7. 单页面应用:现在流行的单页面应用和普通 web 页面的数据采集是否有差异
  8. 混合应用:app 与 h5 的混合应用我们要怎么进行通讯

我们要采集什么内容,进行哪些采集接口的约定

第一期我们先实现对 PV(即页面浏览量或点击量) 、UV(一天内同个访客多次访问) 、点击量、用户的访问路径的基础指标的采集。精细化分析的流量转化需要和业务相关,需要和数据分析方做约定,我们预留扩展。所以我们的采集接口需要进行以下的约定

{
    "header":{ // HTTP 头部
        "X-Device-Id":" 550e8400-e29b-41d4-a716-446655440000", //设备ID,用来区分用户设备
        "X-Source-Url":"https://www.baidu.com/", //源地址,关联用户的整个操作流程,用于用户行为路径分析,例如登录,到首页,进入商品详情,退出这一整个完整的路径
        "X-Current-Url":"", //当前地址,用户行为发生的页面
        "X-User-Id":"",//用户ID,统计登录用户行为
    },
    "body":[{ // HTTP Body体
        "PageSessionID":"", //页面标识ID,用来区分页面事件,例如加载和离开我们会发两个事件,这个标识可以让我们知道这个事件是发生在一个页面上
        "Event":"loaded", //事件类型,区分用户行为事件
        "PageTitle":  "埋点测试页",  //页面标题,直观看到用户访问页面
        "CurrentTime":  “1517798922201”,  //事件发生的时间
        "ExtraInfo":  {
         }    //扩展字段,对具体业务分析的传参
    }]
}

以上就是我们现在约定好了的通用的事件采集的接口,所传的参数基本上会根据采集事件的不同而发生变化。但是在用户的整一个访问行为中,用户的设备是不会变化的,如果你想采集设备信息可以重新约定一个接口,在整个采集开始之前发送设备信息,这样可以避免在事件采集接口上重复采集固定数据。

{
    "header":{ // HTTP 头部
          "X-Device-Id"  :"550e8400-e29b-41d4-a716-446655440000"  ,      //  设备id
    },
    "body":{ // HTTP Body体
              "DeviceType":  "web" ,   //设备类型
             "ScreenWide"  :  768 , //  屏幕宽
             "ScreenHigh":  1366 , //  屏幕高
             "Language":    "zh-cn"  //语言
    }
}

业务方通过什么方式来调用我们的采集脚本

埋点应该让调用的业务方,尽可能少有工作量,最好是什么都不用做,😁,但是实现起来有点难额。我们采用的方案是让业务方在代码里通过 script 脚本来引用我们的 SDK ,业务方只要配置一些需要的参数进行埋点定制(👆我们讲到过的无埋点的流量控制),然后什么都不做就可以进行基础数据的采集。

(function() {
            var collect = document.createElement('script');
            collect.type = 'text/javascript';
            collect.async = true;
            collect.src =  'http://collect.trc.com/index.js';
            var s = document.getElementsByTagName('script')[0];
            s.parentNode.insertBefore(collect, s);
    })();


//用户自定义要进行无埋点采集的元素,如果不进行无埋点采集,可以不配置
 var _XT = [];
  _XT.push(['Target','div']);

手动埋点:SDK

如果业务方需要采集更多业务定制的数据,可以调用我们暴露出的方法进行采集

//自定义事件
  sdk.dispatch('customEvent',{extraInfo:'自定义事件的额外信息'})

游客与用户关联

我们使用 userId 来做用户标识,同一个设备的用户,从游客用户切换到登录用户,如果我们要把他们关联起来,需要有一个设备Id 做关联

web 设备Id

用户通过浏览器来访问 web 页面,设备Id需要存储在浏览器上,同一个用户访问不同的业务方网站,设备Id要保持一样。web 变量存储,我们第一时间想到的就是 cookie,sessionStorage,localStorage,但是这3种存储方式都和访问资源的域名相关。我们总不能每次访问一个网站就新建一个设备指纹吧,所以我们需要通过一个方法来跨域共享设备指纹

我们想到的方案是,通过嵌套 iframe 加载一个静态页面,在 iframe 上加载的域名上存储设备id,通过跨域共享变量获取设备id,共享变量的原理是采用了iframe 的 contentWindow通讯,通过 postMessage 获取事件状态,调用封装好的回调函数进行数据处理具体的实现方式

//web 应用,通过嵌入 iframe 进行跨域 cookie 通讯,设置设备id,
    collect.setIframe = function () {
        var that = this
        var iframe = document.createElement('iframe')
        iframe.id = "frame",
        iframe.src = 'http://collectiframe.trc.com' // 配置域名代理,目的是让开发测试生产环境代码一致
        iframe.style.display='none' //iframe 设置的目的是用来生成固定的设备id,不展示
        document.body.appendChild(iframe)

        iframe.onload = function () {
                iframe.contentWindow.postMessage('loaded','*');
        }

        //监听message事件,iframe 加载完成,获取设备id ,进行相关的数据采集
        helper.on(window,"message",function(event){
            that.deviceId = event.data.deviceId

            if(event.data && event.data.type == 'loaded'){
                that.sendDevice(that.getDevice(), that.deviceUrl);
                setTimeout(function () {
                    that.send(that.beforeload)
                    that.send(that.loaded)
                },1000)
            }
        })
    }

iframe 与 SDK 通讯

function receiveMessageFromIndex ( event ) {
    getDeviceInfo() // 获取设备信息
    var data =  {
            deviceId: _deviceId,
            type:event.data
    }

    event.source.postMessage(data, '*'); // 将设备信息发送给 SDK
}

//监听message事件
if(window.addEventListener){
        window.addEventListener("message", receiveMessageFromIndex, false);
}else{
        window.attachEvent("onmessage", receiveMessageFromIndex, false)

如果你想知道可以看我的另一篇博客 web 浏览器指纹跨域共享

单页面应用:现在流行的单页面应用和普通 web 页面的数据采集是否有差异

我们知道单页面应用都是无刷新的页面加载,所以我们在页面跳转的处理和我们的普通的页面会有所不同。单页面应用的路由插件运用了 window 自带的无刷新修改用户浏览记录的方法,pushState 和 replaceState。

window 的 history 对象 提供了两个方法,能够无刷新的修改用户的浏览记录,pushSate,和 replaceState,区别的 pushState 在用户访问页面后面添加一个访问记录, replaceState 则是直接替换了当前访问记录,所以我们只要改写 history 的方法,在方法执行前执行我们的采集方法就能实现对单页面应用的页面跳转事件的采集了

 // 改写思路:拷贝 window 默认的 replaceState 函数,重写 history.replaceState 在方法里插入我们的采集行为,在重写的 replaceState 方法最后调用,window 默认的 replaceState 方法

    collect = {}
    collect.onPushStateCallback : function(){}  // 自定义的采集方法

    (function(history){
        var replaceState = history.replaceState;   // 存储原生 replaceState
        history.replaceState = function(state, param) {     // 改写 replaceState
           var url = arguments[2];
           if (typeof collect.onPushStateCallback == "function") {
                 collect.onPushStateCallback({state: state, param: param, url: url});   //自定义的采集行为方法
           }
           return replaceState.apply(history, arguments);    // 调用原生的 replaceState
        };
     })(window.history);

这块介绍起来也比较的复杂,如果你想了解更多,可以看我的另一篇博客你需要知道的单页面路由实现原理

混合应用:app 与 h5 的混合应用我们要怎么进行通讯

现在大部分的应用都不是纯原生的应用, app 与 h5 的混合的应用是现在的一种主流。

纯 web 数据采集我们考虑到前端存储数据容易丢失,我们在每一次事件触发的时候都用采集接口传输采集到的数据。考虑到现在很多用户的手机会有流量管家的软件监控,如果在 App 中 h5 还是采集到数据就传输给服务端,很有可能会让流量管家检测到,给用户报警,从而使得用户不再信任你的 App , 所以我们在用户操作的时候将数据传给 app 端,存储到 app。用户切换应用到后台的时候,通过 app 端的 SDK 打包传输到服务器,我们给 app 提供的方法封装了一个适配器

// app 与 h5 混合应用,直接将数信息发给 app
collect.saveEvent = function (jsonString) {

    collect.dcpDeviceType && setTimeout(function () {
        if(collect.dcpDeviceType=='android'){
            android.saveEvent(jsonString)
        } else {
            window.webkit && window.webkit.messageHandlers ? window.webkit.messageHandlers.nativeBridge.postMessage(jsonString) : window.postBridgeMessage(jsonString)
        }

    },1000)
    }

实现思路

通过上面几个问题的思考,我们对埋点的实现大致已经有了一些想法,我们使用思维导图来还原下我们即将要做的事情,图片记得放大看哦,太小了可能看不清。

我们需要暴露给业务方调用的方法

暴露方法
我们需要处理的事件类型
监听事件
SDK 的基本实现思路
实现逻辑

我们来看下几个核心代码的实现

工具方法

我们定义了几个工具方法,提高开发的幸福指数 😝

    var helper = {};

    // 生成一个唯一的标识,pageSessionId (用这个变量来关联开始加载、加载完成、离开页面的事件,计算出页面加菜时间,停留时间)
    helper.uuid = function(){}

    // 元素绑定事件监听,兼容浏览器到IE8
    helper.on = function(){}

    //元素移除事件监听的适配器函数,兼容浏览器到IE8
    helper.remove = function(){}

    //将json转为字符串,事件传输的参数类型转化
    helper.changeJSON2Query = function(){}

    //将相对路径解析成文档全路径
    helper.normalize = function(){}

采集逻辑

    var collect = {
        deviceUrl:'http://collect.trc.com/rest/collect/device/h5/v1',
        eventUrl:'http://collect.trc.com/rest/collect/event/h5/v1',
        isuploadUrl:'http://collect.trc.com/rest/collect/isupload/app/v1',
        parmas:{ ExtraInfo:{} },
        device:{}
    };

    //获取埋点配置
    collect.setParames = function(){}

    //更新访问路径及页面信息
    collect.updatePageInfo = function(){}

    //获取事件参数
    collect.getParames = function(){}

    //获取设备信息
    collect.getDevice = function(){}

    //事件采集
    collect.send = function(){}

    //设备采集
    collect.sendDevice = function(){}

    //判断才否采集,埋点采集的开关
    collect.isupload = function(){

        1. 判断是否采集,不采集就注销事件监听(项目中区分游客身份和用户身份的采集情况,这个方法会被判断两次)
        2. 采集则判断是否已经采集过
            a.已经采集过不做任何操作
            b.没有采集过添加事件监听
        3. 判断是 混合应用还是纯 web 应用
            a.如果是web 应用,调用 collect.setIframe 设置 iframe
            b.如果是混合应用 将开始加载和加载完成事件传输给 app
    }

    //点击事件处理函数
    collect.clickHandler = function(){}

    //离开页面的事件处理函数
    collect.beforeUnloadHandler = function(){}

    //页面回退事件处理函数
    collect.onPopStateHandler = function(){}

    //系统事件初始化,注册离开事件,浏览器后退事件
    collect.event = function(){}

    //获取记录开始加载数据信息
    collect.getBeforeload = function(){}

    //存储加载完成,获取设备类型,记录加载完成信息
    collect.onload = function(){

        1. 判断cookie是否有存设备类型信息,有表示混合应用
        2. 采集加载完成时间等信息
        3. 调用 collect.isupload 判断是否进行采集
    }

    //web 应用,通过嵌入 iframe 进行跨域 cookie 通讯,设置设备id
    collect.setIframe = function(){}

    //app 与 h5 混合应用,直接将数信息发给 app,判断设备类型做原生方法适配器
    collect.saveEvent = function(){}

    //采集自定义事件类型
    collect.dispatch = function(){}

    //将参数 userId 存入sessionStorage
    collect.storeUserId = function(){}

    //采集H5信息,如果是混合应用,将采集到的信息发送给 app 端
    collect.saveEventInfo = function(){}

    //页面初始化调用方法
    collect.init = function(){

        1. 获取开始加载的采集信息
        2. 获取 SDK 配置信息,设备信息
        3. 改写 history 两个方法,单页面应用页面跳转前调用我们自己的方法
        4. 页面加载完成,调用 collect.onload 方法

    }


    collect.init(); // 初始化

    //暴露给业务方调用的方法
    return {
        dispatch:collect.dispatch,
        storeUserId:collect.storeUserId,
    }

扩展

👆就是我这段时间研究的成果了,代码的篇幅比较长,就不放在博客里了,感兴趣的同学可以加我微信进行交流,或则在文章下面留言,也欢迎大家给我提意见,帮忙优化 😝。

web 浏览器指纹跨域共享

你需要知道的单页面路由实现原理

数据埋点是什么?设置埋点的意义是什么?

数据采集与埋点

美团点评前端无痕埋点实践

如何清楚易懂的解释“UV和PV"的定义

查看原文

赞 85 收藏 58 评论 14

Fyerl 关注了专栏 · 2019-03-14

霸天的专栏

不定期更新一些前端技术文章,我也想频率高点,233,很难坚持诶~

关注 288

Fyerl 收藏了文章 · 2019-03-12

前端监控和前端埋点方案设计

在线上项目中,需要统计产品中用户行为和使用情况,从而可以从用户和产品的角度去了解用户群体,从而升级和迭代产品,使其更加贴近用户。用户行为数据可以通过前端数据监控的方式获得,除此之外,前端还需要实现性能监控和异常监控。性能监控包括首屏加载时间、白屏时间、http请求时间和http响应时间。异常监控包括前端脚本执行报错等。

实现前端监控有三个步骤:前端埋点和上报、数据处理和数据分析。本文针对整个前端监控,设计适用的方案。本文的主要内容分为:

  • 为什么需要前端监控
  • 常用前端埋点方案总结
  • 前端埋点方案选型和前端上报方案设计
  • 前端监控结果可视化展示系统的设计

原文的地址,在我的博客中:https://github.com/forthealll...

如有帮助,您的star是对我最好的鼓励~

一、为什么需要前端监控

前端监控的目的是:

获取用户行为以及跟踪产品在用户端的使用情况,并以监控数据为基础,指明产品优化的方向

前端监控可以分为三类:数据监控、性能监控和异常监控。下面我们来一一的了解。

(1)数据监控

数据监控,顾名思义就是监听用户的行为。常见的数据监控包括:

  • PV/UV:PV(page view),即页面浏览量或点击量。UV:指访问某个站点或点击某条新闻的不同IP地址的人数
  • 用户在每一个页面的停留时间
  • 用户通过什么入口来访问该网页
  • 用户在相应的页面中触发的行为

统计这些数据是有意义的,比如我们知道了用户来源的渠道,可以促进产品的推广,知道用户在每一个页面停留的时间,可以针对停留较长的页面,增加广告推送等等。

(2)性能监控

性能监控指的是监听前端的性能,主要包括监听网页或者说产品在用户端的体验。常见的性能监控数据包括:

  • 不同用户,不同机型和不同系统下的首屏加载时间
  • 白屏时间
  • http等请求的响应时间
  • 静态资源整体下载时间
  • 页面渲染时间
  • 页面交互动画完成时间

这些性能监控的结果,可以展示前端性能的好坏,根据性能监测的结果可以进一步的去优化前端性能,比如兼容低版本浏览器的动画效果,加快首屏加载等等。

(3)异常监控

此外,产品的前端代码在执行过程中也会发生异常,因此需要引入异常监控。及时的上报异常情况,可以避免线上故障的发上。虽然大部分异常可以通过try catch的方式捕获,但是比如内存泄漏以及其他偶现的异常难以捕获。常见的需要监控的异常包括:

  • Javascript的异常监控
  • 样式丢失的异常监控

二、常用前端埋点方案总结

在上一节中介绍了前端监控的作用,那么如何实现前端监控呢,实现前端监控的步骤为:前端埋点和上报、数据处理和数据分析。首要的步骤就是前端埋点和上报,也就是数据的收集阶段。数据收集的丰富性和准确性会影响对产品线上效果的判别结果。

目前常见的前端埋点方法分为三种:代码埋点、可视化埋点和无痕埋点。下面一一介绍每一种埋点的方法。

(1) 代码埋点

代码埋点,就是以嵌入代码的形式进行埋点,比如需要监控用户的点击事件,会选择在用户点击时,插入一段代码,保存这个监听行为或者直接将监听行为以某一种数据格式直接传递给server端。此外比如需要统计产品的PV和UV的时候,需要在网页的初始化时,发送用户的访问信息等。

代码埋点的优点:

  • 可以在任意时刻,精确的发送或保存所需要的数据信息。

缺点:

  • 工作量较大,每一个组件的埋点都需要添加相应的代码

(2)可视化埋点

通过可视化交互的手段,代替代码埋点。将业务代码和埋点代码分离,提供一个可视化交互的页面,输入为业务代码,通过这个可视化系统,可以在业务代码中自定义的增加埋点事件等等,最后输出的代码耦合了业务代码和埋点代码。

可视化埋点听起来比较高大上,实际上跟代码埋点还是区别不大。也就是用一个系统来实现手动插入代码埋点的过程。

缺点:

  • 可视化埋点可以埋点的控件有限,不能手动定制。

(3)无埋点

无埋点并不是说不需要埋点,而是全部埋点,前端的任意一个事件都被绑定一个标识,所有的事件都别记录下来。通过定期上传记录文件,配合文件解析,解析出来我们想要的数据,并生成可视化报告供专业人员分析因此实现“无埋点”统计。

从语言层面实现无埋点也很简单,比如从页面的js代码中,找出dom上被绑定的事件,然后进行全埋点。

无埋点的优点:

  • 由于采集的是全量数据,所以产品迭代过程中是不需要关注埋点逻辑的,也不会出现漏埋、误埋等现象

缺点:

  • 无埋点采集全量数据,给数据传输和服务器增加压力
  • 无法灵活的定制各个事件所需要上传的数据

三、前端埋点方案选型和前端上报方案设计

第一章中介绍了前端所需要监听的信息,在第二章中介绍了前端埋点的常见方式,本文来根据需求,来定制我们的埋点和上报方案。

(1)监控数据

首先我们需要明确一个产品或者网页,普遍需要监控和上报的数据。监控的分为三个阶段:用户进入网页首页、用户在网页内部交互和交互中报错。每一个阶段需要监控和上报的数据如下图所示:

default

(2)埋点方案

在实际项目中考虑到上报数据的灵活定制,以及减少数据传输和服务器的压力,在所需埋点处不多的情况下,常用的方式是代码埋点。

以用户进入首页为例,我们在首页渲染完成后会发送事件类型和类型相关的数据给server端,告知首页的监控信息。

default

(3)上报周期和上报数据类型

如果埋点的事件不是很多,上报可以时时进行,比如监控用户的交互事件,可以在用户触发事件后,立刻上报用户所触发的事件类型。如果埋点的事件较多,或者说网页内部交互频繁,可以通过本地存储的方式先缓存上报信息,然后定期上报。

接着来确定需要埋点上报的数据,上报的信息包括用户个人信息以及用户行为,主要数据可以分为:

  • who: appid(系统或者应用的id),userAgent(用户的系统、网络等信息)
  • when: timestamp(上报的时间戳)
  • from where: currentUrl(用户当前url),fromUrl(从哪一个页面跳转到当前页面),type(上报的事件类型),element(触发上报事件的元素)
  • what: 上报的自定义扩展数据data:{},扩展数据中可以按需求定制,比如包含uid等信息

上报数据的对象为:

{   
    ----------------上报接口本身提供--------------------
    currentUrl,  
    fromUrl,
    timestamp,
    userAgent:{
       os,
       netWord,
    }
    ----------------业务代码配置和自定义上报数据------------
    type,
    appid,
    element,
    data:{
        uid,
        uname
    }
}

(4)埋点和上报举例

我们以上报首屏加载事件为例,DOM提供了document的DOMContentLoaded事件来监听dom挂载,提供了window的load事件来监听页面所有资源加载渲染完毕。

<script type="text/javascript">
  var start=Date.now();
  document.addEventListener('DOMContentLoaded', function() {
     fetch('some api',{
         type:'dom complete',
         data:{
           domCompletedTime:Date.now()-start
         }
     })
  });
  window.addEventListener('load', function() {
     fetch('some api',{
         type:'load complete',
         data:{
           LoadCompletedTime:Date.now()-start
         }
     })
  });
</script>

(5)前端埋点系统的前后端通信加密

在上报数据的前后端通信中,需要和server端协商加密机制,利用 OpenSSL库来实现的加密,OpenSSL已经是一个广泛被采用的加密算法。前端可以采用node的crypto模块。

首先来看hash算法,crypto.createHash() 来创建一个Hash实例,可利用的hash算法如下:

  • md5
  • sha1
  • sha256
  • sha512
  • ripemd160

以sha256算法加密为例:


const str="123445";//需要加密的字段
const hash=crypto.createHash('sha256');//指定加密算法
hash.update(str); //通过算法加密相应的字段
const result=hash.digest('hex');//转化成十六进制

四、前端监控结果可视化展示系统的设计

当后端得到前端上报的信息之后,经过数据分析和处理,需要前端可视化的展示数据分析后的结果。

可以在开源中后台系统ant-design-pro的基础上进行二次开发,首先要明确展示信息。展示的信息包括单个用户和整体应用。

对于单个用户来说需要展示的监控信息为:

  • 单个用户,在交互过程中触发各个埋点事件的次数
  • 单个用户,在某个时间周期内,访问本网页的入口来源
  • 单个用户,在每一个子页面的停留时间

对于全体用户需要展示的信息为:

  • 某一个时间段内网页的PV和UV
  • 全体用户访问网页的设备和操作系统分析
  • 某一个时间段内访问本网页的入口来源分析
  • 全体用户在访问本网页时,在交互过程中触发各个埋点事件的总次数
  • 全体用户在访问本网页时,网页上报异常的集合

删选功能集合:

  • 时间筛选:提供今日(00点到当前时间)、本周、本月和全年
  • 用户删选:提供根据用户id删选出用户行为的统计信息
  • 设备删选:删选不同系统的整体展示信息
查看原文

Fyerl 评论了文章 · 2018-10-11

基于百度地图的区域重叠判断

原文链接: Fyerl's Blog

前些日的一个小需求:
用户在后台划不规则区域,区域之间不能重叠,如图
图片描述

判断分两步:
1、判断多变形是否有相交线段,无则进行第二步判断(公式)
2、判断多变形之间是否存在顶点与多边形的包含关系(BMapLib.GeoUtils.isPointInPolygon)

代码如下:

/**
 * 点: { lat: xxx, lng: xxx }
 * 线: [{ lat: xxx, lng: xxx }, { lat: xxx, lng: xxx }]
 * 面: [{ lat: xxx, lng: xxx }, { lat: xxx, lng: xxx }, { lat: xxx, lng: xxx }...]
 * */
const { BMap, BMapLib } = window;

/**
 * 线段是否相交
 * seg: [{ lat: xxx, lng: xxx }, { lat: xxx, lng: xxx }]
 * */
function isSegmentsIntersectant(segA, segB) {
  const abc = (segA[0].lat - segB[0].lat) * (segA[1].lng - segB[0].lng) - (segA[0].lng - segB[0].lng) * (segA[1].lat - segB[0].lat);
  const abd = (segA[0].lat - segB[1].lat) * (segA[1].lng - segB[1].lng) - (segA[0].lng - segB[1].lng) * (segA[1].lat - segB[1].lat);
  if (abc * abd >= 0) {
    return false;
  }

  const cda = (segB[0].lat - segA[0].lat) * (segB[1].lng - segA[0].lng) - (segB[0].lng - segA[0].lng) * (segB[1].lat - segA[0].lat);
  const cdb = cda + abc - abd;
  return !(cda * cdb >= 0);
}

/**
 * 判断两多边形边界是否相交
 */
function isPolygonsIntersectant(plyA, plyB) {
  for (let i = 0, il = plyA.length; i < il; i++) {
    for (let j = 0, jl = plyB.length; j < jl; j++) {
      const segA = [plyA[i], plyA[i === il - 1 ? 0 : i + 1]];
      const segB = [plyB[j], plyB[j === jl - 1 ? 0 : j + 1]];
      if (isSegmentsIntersectant(segA, segB)) {
        return true;
      }
    }
  }
  return false;
}

/**
 * 判断两多变形是否存在点与区域的包含关系(A的点在B的区域内或B的点在A的区域内)
 */
function isPointInPolygonBidirectional(plyA, plyB) {
  const [pA, pB] = [[], []];
  plyA.forEach((item) => {
    pA.push(new BMap.Point(item.lng, item.lat));
  });

  plyB.forEach((item) => {
    pB.push(new BMap.Point(item.lng, item.lat));
  });


  let [a, b] = [false, false];
  a = pA.some(item => BMapLib.GeoUtils.isPointInPolygon(item, new BMap.Polygon(pB)));
  if (!a) {
    b = pB.some(item => BMapLib.GeoUtils.isPointInPolygon(item, new BMap.Polygon(pA)));
  }

  return a || b;
}


/**
 * 判断多边形是否重叠
 * */
export function isPolygonsOverlap(plyA, plyB) {
  return isPolygonsIntersectant(plyA, plyB) || isPointInPolygonBidirectional(plyA, plyB);
}
查看原文

Fyerl 评论了文章 · 2018-08-20

基于 vue 的 picker 组件 vue-awesome-picker

⚠️ DEPRECATED 本组件停止维护
⚠️ Vue 组件库请移步 Vant
⚠️ 小程序组件库请移步 Vant Weapp
⚠️ 有赞前端大量坑位,内推私信

vue-awesome-picker NPM VersionNPM Downloads

基于 Vue.js & Better-Scroll 的移动端 picker 组件

Features

  • 支持单列、多列和联级数据
  • 内置时间、日期数据
  • 滚轮 3D 效果
  • 颜色可配置

Demo

PS:Demo 已启用 Service Worker 试试离线访问吧

点击查看 >>

Installation

npm install vue-awesome-picker --save

Usage

/* main.js */
import AwesomePicker from 'vue-awesome-picker';
Vue.use(AwesomePicker);
/* 详细使用方法参照源码 App.vue */
<awesome-picker
  ref="picker"
  :data="picker.data"
  :anchor="picker.anchor"
  :textTitle="picker.textTitle"
  :textConfirm="picker.textConfirm"
  :textCancel="picker.textCancel"
  :colorTitle="picker.colorTitle"
  :colorConfirm="picker.colorConfirm"
  :colorCancel="picker.colorCancel"
  :swipeTime="picker.swipeTime"
  @cancel="handlePickerCancel"
  @confirm="handlePickerConfirm">
</awesome-picker>
methods: {
  show() {
    this.$refs.picker.show();
  }
}

Props

参数描述可选类型默认
data详细描述见下文Array
anchor详细描述见下文Array
type内置 picker 类型
无需传入 data
date, timeString
textTitletitle 文案String
textConfirmconfirm 文案String确定
textCancelcancel 文案String取消
colorTitletitle 颜色String#000000
colorConfirmconfirm 颜色String#42b983
colorCancelcancel 颜色String#999999
swipeTime滚动速度(better-scroll swipeTime)Number1800

data

vue-awesome-picker 通过数据结构不同来区分是普通 picker 还是联级 picker, 所以请严格按照以下数据结构进行配置

单列、多列 picker 以双层数组的形式传入 data

[
  ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's','t', 'u', 'v', 'w', 'x', 'y', 'z'],
  ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S','T', 'U', 'V', 'W', 'X', 'Y', 'Z']
]

联级 picker 通过 children 构造出具有层级关系的数据

[
  {
    value: 'A',
    children: [
      { value: 'A-a' },
      { value: 'A-b' },
      { value: 'A-c' }
    ]
  },
  {
    value: 'B',
    children: [
      { value: 'B-a' },
      { value: 'B-b' }
    ]
  },
]

anchor

anchor 是 picker 展开时每一列默认滚动的锚点位置或值的数组, 兼容两种数据结构, 未匹配到默认选中第0项

[推荐]数组对象形式: 与事件 confirm 返回的参数数据结构相同, 对象里可以只存在 index 或 value, 当存在 index 时优先匹配 index

[
  { 
    index: 0,
    value: 'A'
  },
  {
    index: 0,
    value: 'A-a'
  } 
]

单层数组形式: index 组成的数组

[0, 0]

Methods

方法描述
show展开显示 picker

Events

事件描述参数
confirm点击 confirm 按钮后触发[{ index: xxx, value: xxx }...]
index: 当前选中的 item 在当列的 index
value: 当前选中 item 的 value
cancel点击 cancel 按钮后触发

Development

git clone git@github.com:Fyerl/vue-awesome-picker.git
cd vue-awesome-picker
npm install
npm run dev

keywords for seo: js picker, 前端 picker, datepicker, timepicker, vue, picker, vue picker, vue awesome picker, service worker, pwa, vue pwa, npm, vue npm

查看原文

认证与成就

  • 获得 53 次点赞
  • 获得 9 枚徽章 获得 0 枚金徽章, 获得 1 枚银徽章, 获得 8 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

注册于 2016-01-24
个人主页被 1.3k 人浏览