weiwei

weiwei 查看完整档案

深圳编辑  |  填写毕业院校  |  填写所在公司/组织 lweiwei.com 编辑
编辑

前端人

个人动态

weiwei 关注了用户 · 8月16日

乌柏木 @wubomu

曾在阿里巴巴跑龙套,现在自己公司打酱油。

关注 929

weiwei 赞了文章 · 7月15日

GitLab 内置了一个强大的 CI/CD 系统

持续集成.jpg

来源:
https://www.cnblogs.com/cjsbl...

GitLab CI/CD 是一个内置在GitLab中的工具,用于通过持续方法进行软件开发:

  • Continuous Integration (CI)  持续集成
  • Continuous Delivery (CD)     持续交付
  • Continuous Deployment (CD)   持续部署

持续集成的工作原理是将小的代码块推送到Git仓库中托管的应用程序代码库中,并且每次推送时,都要运行一系列脚本来构建、测试和验证代码更改,然后再将其合并到主分支中。

持续交付和部署相当于更进一步的CI,可以在每次推送到仓库默认分支的同时将应用程序部署到生产环境。

这些方法使得可以在开发周期的早期发现bugs和errors,从而确保部署到生产环境的所有代码都符合为应用程序建立的代码标准。

GitLab CI/CD 由一个名为 .gitlab-ci.yml 的文件进行配置,改文件位于仓库的根目录下。文件中指定的脚本由GitLab Runner执行。

1. GitLab CI/CD 介绍

软件开发的持续方法基于自动执行脚本,以最大程度地减少在开发应用程序时引入错误的机会。从开发新代码到部署新代码,他们几乎不需要人工干预,甚至根本不需要干预。

它涉及到在每次小的迭代中就不断地构建、测试和部署代码更改,从而减少了基于已经存在bug或失败的先前版本开发新代码的机会。

Continuous Integration(持续集成)

假设一个应用程序,其代码存储在GitLab的Git仓库中。开发人员每天都要多次推送代码更改。对于每次向仓库的推送,你都可以创建一组脚本来自动构建和测试你的应用程序,从而减少了向应用程序引入错误的机会。这种做法称为持续集成,对于提交给应用程序(甚至是开发分支)的每项更改,它都会自动连续进行构建和测试,以确保所引入的更改通过你为应用程序建立的所有测试,准则和代码合规性标准。

Continuous Delivery(持续交付)

持续交付是超越持续集成的更进一步的操作。应用程序不仅会在推送到代码库的每次代码更改时进行构建和测试,而且,尽管部署是手动触发的,但作为一个附加步骤,它也可以连续部署。此方法可确保自动检查代码,但需要人工干预才能从策略上手动触发以必输此次变更。

Continuous Deployment(持续部署)

与持续交付类似,但不同之处在于,你无需将其手动部署,而是将其设置为自动部署。完全不需要人工干预即可部署你的应用程序。

1.1. GitLab CI/CD 是如何工作的

为了使用GitLab CI/CD,你需要一个托管在GitLab上的应用程序代码库,并且在根目录中的.gitlab-ci.yml文件中指定构建、测试和部署的脚本。

在这个文件中,你可以定义要运行的脚本,定义包含的依赖项,选择要按顺序运行的命令和要并行运行的命令,定义要在何处部署应用程序,以及指定是否 要自动运行脚本或手动触发脚本。

为了可视化处理过程,假设添加到配置文件中的所有脚本与在计算机的终端上运行的命令相同。

一旦你已经添加了.gitlab-ci.yml到仓库中,GitLab将检测到该文件,并使用名为GitLab Runner的工具运行你的脚本。该工具的操作与终端类似。

这些脚本被分组到jobs,它们共同组成一个pipeline。一个最简单的.gitlab-ci.yml文件可能是这样的:

before_script:  
  - apt-get install rubygems ruby-dev -y  
  
run-test:  
  script:  
    - ruby --version 6  

before_script属性将在运行任何内容之前为你的应用安装依赖,一个名为run-test的job(作业)将打印当前系统的Ruby版本。二者共同构成了在每次推送到仓库的任何分支时都会被触发的pipeline(管道)。

GitLab CI/CD不仅可以执行你设置的job,还可以显示执行期间发生的情况,正如你在终端看到的那样:

为你的应用创建策略,GitLab会根据你的定义来运行pipeline。你的管道状态也会由GitLab显示:

最后,如果出现任何问题,可以轻松地回滚所有更改:

1.2. 基本 CI/CD 工作流程

一旦你将提交推送到远程仓库的分支上,那么你为该项目设置的CI/CD管道将会被触发。GitLab CI/CD 通过这样做:

  • 运行自动化脚本(串行或并行) 代码Review并获得批准
  • 构建并测试你的应用
  • 就像在你本机中看到的那样,使用Review Apps预览每个合并请求的更改
  • 代码Review并获得批准
  • 合并feature分支到默认分支,同时自动将此次更改部署到生产环境
  • 如果出现问题,可以轻松回滚 通过GitLab UI所有的步骤都是可视化的:

1.3. 深入了解CI/CD基本工作流程

如果我们深入研究基本工作流程,则可以在DevOps生命周期的每个阶段看到GitLab中可用的功能,如下图所示:

  1. Verify
  • 通过持续集成自动构建和测试你的应用程序
  • 使用GitLab代码质量(GitLab Code Quality)分析你的源代码质量
  • 通过浏览器性能测试(Browser Performance Testing)确定代码更改对性能的影响
  • 执行一系列测试,比如Container Scanning , Dependency Scanning , JUnit tests
  • 用Review Apps部署更改,以预览每个分支上的应用程序更改
  1. Package
  • 用Container Registry存储Docker镜像
  • 用NPM Registry存储NPM包
  • 用Maven Repository存储Maven artifacts
  • 用Conan Repository存储Conan包
  1. Release
  • 持续部署,自动将你的应用程序部署到生产环境
  • 持续交付,手动点击以将你的应用程序部署到生产环境
  • 用GitLab Pages部署静态网站
  • 仅将功能部署到一个Pod上,并让一定比例的用户群通过Canary Deployments访问临时部署的功能(PS:即灰度发布)
  • 在Feature Flags之后部署功能
  • 用GitLab Releases将发布说明添加到任意Git tag
  • 使用Deploy Boards查看在Kubernetes上运行的每个CI环境的当前运行状况和状态
  • 使用Auto Deploy将应用程序部署到Kubernetes集群中的生产环境

使用GitLab CI/CD,还可以:

  • 通过Auto DevOps轻松设置应用的整个生命周期
  • 将应用程序部署到不同的环境
  • 安装你自己的GitLab Runner
  • Schedule pipelines
  • 使用安全测试报告(Security Test reports)检查应用程序漏洞
2. GitLab CI/CD 快速开始

.gitlab-ci.yml文件告诉GitLab Runner要做什么。一个简单的管道通常包括三个阶段:build、test、deploy 管道在 CI/CD > Pipelines 页面

2.1. 创建一个 .gitlab-ci.yml 文件

通过配置.gitlab-ci.yml文件来告诉CI要对你的项目做什么。它位于仓库的根目录下。仓库一旦收到任何推送,GitLab将立即查找.gitlab-ci.yml文件,并根据文件的内容在Runner上启动作业。

下面是一个Ruby项目配置例子:

image: "ruby:2.5"  
  
 before_script:  
 - apt-get update -qq && apt-get install -y -qq sqlite3 libsqlite3-dev nodejs  
 - ruby -v  
 - which ruby  
 - gem install bundler --no-document  
 - bundle install --jobs $(nproc) "${FLAGS[@]}"  
  
 rspec:  
 script:  
 - bundle exec rspec  
  
 rubocop:  
 script:  
 - bundle exec rubocop

上面的例子中,定义里两个作业,分别是 rspec 和 rubocop,在每个作业开始执行前,要先执行before_script下的命令

2.2. 推送 .gitlab-ci.yml 到 GitLab

git add .gitlab-ci.yml
git commit -m "Add .gitlab-ci.yml" 
git push origin master

2.3. 配置一个Runner

在GitLab中,Runner运行你定义在.gitlab-ci.yml中的作业(job) 一个Runner可以是一个虚拟机、物理机、docker容器,或者一个容器集群 GitLab与Runner之间通过API进行通信,因此只需要Runner所在的机器有网络并且可以访问GitLab服务器即可 你可以去 Settings ➔ CI/CD 看是否已经有Runner关联到你的项目,设置Runner简单又直接。

2.4. 查看 pipeline 和 jobs的状态

在成功配置Runner以后,你应该可以看到你最近的提交的状态

为了查看所有jobs,你可以去 Pipelines ➔ Jobs 页面

通过点击作业的状态,你可以看到作业运行的日志

回顾一下:

  • 1、首先,定义.gitlab-ci.yml文件。在这个文件中就定义了要执行的job和命令
  • 2、接着,将文件推送至远程仓库
  • 3、最后,配置Runner,用于运行job
3. Auto DevOps

Auto DevOps 提供了预定义的CI/CD配置,使你可以自动检测,构建,测试,部署和监视应用程序。借助CI/CD最佳实践和工具,Auto DevOps旨在简化成熟和现代软件开发生命周期的设置和执行。

借助Auto DevOps,软件开发过程的设置变得更加容易,因为每个项目都可以使用最少的配置来完成从验证到监视的完整工作流程。只需推送你的代码,GitLab就会处理其他所有事情。这使得启动新项目更加容易,并使整个公司的应用程序设置方式保持一致。

下面这个例子展示了如何使用Auto DevOps将GitLab.com上托管的项目部署到Google Kubernetes Engine

示例中会使用GitLab原生的Kubernetes集成,因此不需要再单独手动创建Kubernetes集群

本例将创建并部署一个从GitLab模板创建的应用

3.1. 从GitLab模板创建项目

在创建Kubernetes集群并将其连接到GitLab项目之前,你需要一个Google Cloud Platform帐户

下面使用GitLab的项目模板来创建一个新项目

给项目起一个名字,并确保它是公有的

3.2. 从GitLab模板创建Kubernetes集群

点击 Add Kubernetes cluster 按钮,或者 Operations > Kubernetes

安装Helm, Ingress, 和 Prometheus

3.3. 启用Auto DevOps (可选)

Auto DevOps 默认是启用的。导航栏 Settings > CI/CD > Auto DevOps,勾选 Default to Auto DevOps pipeline,最后选择部署策略:

一旦你已经完成了以上所有的操作,那么一个新的 pipeline 将会被自动创建。为了查看pipeline,可以去 CI/CD > Pipelines

3.4. 部署应用

到目前为止,你应该看到管道正在运行,但是它到底在运行什么呢?

管道内部分为4个阶段,我们可以查看每个阶段有几个作业在运行,如下图:

构建 -> 测试 -> 部署 -> 性能测试

现在,应用已经成功部署,让我们通过浏览器查看。

首先,导航到 Operations > Environments

在Environments中,可以看到部署的应用的详细信息。在最右边有三个按钮,我们依次来看一下:

第一个图标将打开在生产环境中部署的应用程序的URL。这是一个非常简单的页面,但重要的是它可以正常工作!

紧挨着第二个是一个带小图像的图标,Prometheus将在其中收集有关Kubernetes集群以及应用程序如何影响它的数据(在内存/ CPU使用率,延迟等方面)

第三个图标是Web终端,它将在运行应用程序的容器内打开终端会话。

4. Examples

使用GitLab CI/CD部署一个Spring Boot应用。

示例 .gitlab-ci.yml

image: java:8  
  
 stages:  
 - build  
 - deploy  
  
 before_script:  
 - chmod +x mvnw  
  
 build:  
 stage: build  
 script: ./mvnw package  
 artifacts:  
 paths:  
 - target/demo-0.0.1-SNAPSHOT.jar  
  
 production:  
 stage: deploy  
 script:  
 - curl --location "https://cli.run.pivotal.io/stable?release=linux64-binary&source=github" | tar zx  
 - ./cf login -u $CF_USERNAME -p $CF_PASSWORD -a api.run.pivotal.io  
 - ./cf push  
 only:  
 - master

在公众号对话框回复「1024即可免费获取技术资源!!

jishuroad.jpg

查看原文

赞 12 收藏 9 评论 0

weiwei 赞了文章 · 3月2日

Webpack4不求人系列(1)

Webpack是一个现在Javascript应用程序的模块化打包器,在Webpack中JS/CSS/图片等资源都被视为JS模块,简化了编程。当Webpack构建时,会递归形成一个模块依赖关系图,然后将所有的模块打包为一个或多个bundle。

img

本文内容

  1. 简介
  2. 常用loader && plugin
  3. 传统网站的webpack配置

简介

要系统地学习Webpack,需要先了解Webpack的四个核心概念:

  • 入口(entry)
  • 输出(output)
  • loader
  • plugin

webpack使用Node.js运行,因此所有的Node.js模块都可以使用,比如文件系统、路径等模块。

对Node.js基础不太了解的读者,可以参考我的Node.js系列

配置文件webpack.config.js的一般格式为:

const path = require('path'); // 导入Node.js的path模块


module.exports = {
  mode: 'development', // 工作模式
  entry: './src/index', // 入口点
  output: { // 输出配置
    path: path.resolve(__dirname, 'dist'), // 输出文件的目录
    filename: 'scripts/[name].[hash:8].js', // 输出JS模块的配置
    chunkFilename:'scripts/[name].[chunkhash:8].js', // 公共JS配置
    publicPath:'/' // 资源路径前缀,一般会使用CDN地址,这样图片和CSS就会使用CDN的绝对URL
  },
  module:{
    rules: [
      {
        test:/\.(png|gif|jpg)$/, // 图片文件
        use:[
          {
            loader:'file-loader', // 使用file-loader加载
            options:{ // file-loader使用的加载选项
              name:'images/[name].[hash:8].[ext]' // 图片文件打包后的输出路径配置
            }
          }
        ]
      }
    ]
  },
  plugins:[ // 插件配置
    new CleanWebpackPlugin()
  ]
};
Webpack自己只管JS模块的输出,也就是output.filename是JS的配置,CSS、图片这些是通过loader来处理输出的

入口

入口指明了Webpack从哪个模块开始进行构建,Webpack会分析入口模块依赖到的模块(直接或间接),最终输出到一个被称为bundle的文件中。

使用entry来配置项目入口。

单一入口

最终只会生成1个js文件

module.exports = {
  entry: './src/index',
};

多个入口

最终会根据入口数量生成对应的js文件

module.exports = {
  entry:{
      home:'./src/home/index', // 首页JS
    about:'./src/about/index' // 关于页JS
  }
};

多个入口一般会在多页面应用中使用,比如传统的新闻网站。

输出

输出指明了Webpack将bundle输出到哪个目录,以及这些bundle如何命名等,默认的目录为./dist

module.exports = {
  output:{
    path:path.resolve(__dirname, 'dist'), // 输出路径
    filename:'scripts/[name].[hash:8].js', // 输出JS模块的文件名规范
    chunkFilename:'scripts/[name].[chunkhash:8].js', // 公共JS的配置
    publicPath:'/', // 资源路径前缀,一般会使用CDN地址,这样图片和CSS就会使用CDN的绝对URL
  }
};

path

path是打包后bundle的输出目录,必须使用绝对路径。所有类型的模块(js/css/图片等)都会输出到该目录中,当然,我们可以通过配置输出模块的名称规则来输出到path下的子目录。比如上例中最终输出的JS目录如下:

|----dist
         |---- scripts
                      |---- home.aaaaaaaa.js

filename

入口模块输出的命名规则,在Webpack中,只有js是亲儿子,可以直接被Webpack处理,其他类型的文件(css/images等)需要通过loader来进行转换。

filename的常用的命名如下:

[name].[hash].js
  • [name] 为定义入口模块的名称,比如定义了home的入口点,这里的name最终就是home
  • [hash] 是模块内容的MD5值,一次打包过程中所有模块的hash值是相同的,由于浏览器会按照文件名缓存,因此每次打包都需要指定hash来改变文件名,从而清除缓存。

chunkFilename

非入口模块输出的命名规则,一般是代码中引入其他依赖,同时使用了optimization.splitChunks配置会抽取该类型的chunk

hash

Webpack中常见的hash有hash,contenthash,chunkhash,很容易弄混淆,这里说明一下。

  • hash 整个项目公用的hash值,不管修改项目的什么文件,该值都会变化
  • chunkhash 公共代码模块的hash值,只要不改该chunk下的代码,该值不会变化
  • contenthash 基于文件内容生成的hash,只要改了文件,对应的hash都会变化

publicPath

资源的路径前缀,打包之后的资源默认情况下都是相对路径,当更改了部署路径或者需要使用CDN地址时,该选项比较常用。

比如我们把本地编译过程中产生的所有资源都放到一个CDN路径中,可以这么定义:

publicPath: 'https://static.ddhigh.com/blog/'

那么最终编译的js,css,image等路径都是绝对链接。

loader

loader用来在import时预处理文件,一般用来将非JS模块转换为JS能支持的模块,比如我们直接import一个css文件会提示错误,此时就需要loader做转换了。

比如我们使用loader来加载css文件。

module.exports = {
  module:{
    rules:[
      {
        test: /\.(css)$/,
                use: ['css-loader']
      }
    ]
  }
};

配置方式

Webpack中有3种使用loader的方式:

  1. 配置式:在webpack.config.js根据文件类型进行配置,这是推荐的配置
  2. 内联:在代码中import时指明loader
  3. 命令行:通过cli命令行配置

配置式

module.rules用来配置loader。test用来对加载的文件名(包括目录)进行正则匹配,只有当匹配时才会应用对应loader。

多个loader配置时从右向左进行应用

配置式Webpack的loader也有好几种形式,有些是为了兼容而添加的,主要使用的方式有以下3种。

module.exports = {
  module:{
    rules:[
      {
        test: /\.less$/,
        loader:'css-loader!less-loader', // 多个loader中用感叹号分隔
      },
      {
        test:/\.css/,
        use:['css-loader'],//数组形式
      },
      {
        test:/\.(png|gif|jpg)$/,
        use:[ // loader传递参数时建议该方法
          {
            loader: 'file-loader',
            options:{ // file-loader自己的参数,跟webpack无关
              name: 'images/[name].[hash:8].js'
            }
          }
        ]
      }
    ]
  }
};
每个loader的options参数不一定相同,这个需要查看对应loader的官方文档。

Plugin

loader一般用来做模块转换,而插件可以执行更多的任务,包括打包优化、压缩、文件拷贝等等。插件的功能非常强大,可以进行各种各样的任务。

下面是打包之前清空dist目录的插件配置示例。

const { CleanWebpackPlugin } = require('clean-webpack-plugin');
module.exports = {
    plugins: [
        new CleanWebpackPlugin(),
      ]
};

插件也可以传入选项,一般在实例化时进行传入。

new MiniCssPlugin({
    filename: 'styles/[name].[contenthash:8].css',
  chunkFilename: 'styles/[name].[contenthash:8].css'
})

提取公共代码

Webpack4中提取公共代码只需要配置optimization.splitChunks即可。

optimization: {
    splitChunks: {
      cacheGroups: {
        vendor: { // 名为vendor的chunk
          name: "vendor",
        test: /[\\/]node_modules[\\/]/,
        chunks: 'all',
        priority: 10
      },
          styles: { // 名为styles的chunk
        name: 'styles',
        test: /\.css$/,
        chunks: 'all'
      }
    }
  }
},
  • cacheGroups 缓存组
  • name chunk的名称
  • test 加载的模块符合该正则时被打包到该chunk
  • chunks 模块的范围,有initial(初始模块),async(按需加载模块),all(全部模块)

上面的例子中将node_modules中的js打包为vendor,以css结尾的打包为styles

常用的loader和plugin

css-loader

加载css文件
{
  test:/\.css$/
  loader:['css-loader']
}

less-loader

加载less文件,一般需要配合css-loader
{
  test:/\.less$/,
  loader:['css-loader','less-loader']
}

file-loader

将文件拷贝到输出文件夹,并返回相对路径。一般常用在加载图片
{
  test:/\.(png|gif|jpg)/,
  use:[
      {
      loader:'file-loader',
      options:{
        name:'images/[name].[hash:8].[ext]'
      }
    }    
  ]
}

babel-loader

转换ES2015+代码到ES5
{
  test:/\.js$/,
  exclude: /(node_modules|bower_components)/, // 排除指定的模块
  use:[
    {
      loader:'babel-loader',
      options:{
        presets:['@babel/preset-env']
      }
    }
  ]
}

ts-loader

转换Typescript到Javascript
{
  test:/\.ts/,
  loader:'ts-loader'
}

html-webpack-plugin

简化HTML的创建,该插件会自动将当前打包的资源(如JS、CSS)自动引用到HTML文件
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports =  {
  plugins:[
    new HtmlWebpackPlugin()
  ]
};

clean-webpack-plugin

打包之前清理dist目录
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
module.exports =  {
  plugins:[
    new CleanWebpackPlugin()
  ]
};

mini-css-extract-plugin

提取、压缩CSS,需要同时配置loader和plugin
const MiniCssPlugin = require('mini-css-extract-plugin');
module.exports =  {
  module:{
    rules:[
            {
                test: /\.less$/,
                use: [MiniCssPlugin.loader, 'css-loader', 'less-loader']
            },
            {
                test: /\.css$/,
                use: [MiniCssPlugin.loader, 'css-loader']
            },
    ]
  },
  plugins:[
    new MiniCssPlugin({
            filename: 'styles/[name].[contenthash:8].css',
      chunkFilename: 'styles/[name].[contenthash:8].css'
    }),
  ]
};

实战

下面使用Webpack来配置一个传统多页面网站开发的示例。

目录结构

├── package.json
├── src
│   ├── about                        关于页
│   │   ├── index.html
│   │   ├── index.js
│   │   └── style.less
│   ├── common
│   │   └── style.less
│   └── home                        首页
│       ├── images
│       │   └── logo.png
│       ├── index.html
│       ├── index.js
│       └── style.less
├── webpack.config.js

使用到的npm包

"clean-webpack-plugin": "^3.0.0",
"css-loader": "^3.2.1",
"exports-loader": "^0.7.0",
"extract-text-webpack-plugin": "^4.0.0-beta.0",
"file-loader": "^5.0.2",
"html-webpack-plugin": "^3.2.0",
"html-withimg-loader": "^0.1.16",
"less": "^3.10.3",
"less-loader": "^5.0.0",
"mini-css-extract-plugin": "^0.8.0",
"normalize.css": "^8.0.1",
"script-loader": "^0.7.2",
"style-loader": "^1.0.1",
"url-loader": "^3.0.0",
"webpack": "^4.41.2",
"webpack-cli": "^3.3.10",
"webpack-dev-server": "^3.9.0",
"zepto": "^1.2.0"

配置入口点

由于是传统多页网站,每个页面都需要单独打包一份JS,因此每个页面需要一个入口

entry: { // 入口配置,每个页面一个入口JS
        home: './src/home/index', // 首页
        about: './src/about/index' // 关于页
}

配置输出

本例我们不进行CDN部署,因此输出点配置比较简单。

output: { // 输出配置
  path: path.resolve(__dirname, 'dist'), // 输出资源目录
  filename: 'scripts/[name].[hash:8].js', // 入口点JS命名规则
  chunkFilename: 'scripts/[name]:[chunkhash:8].js', // 公共模块命名规则 
  publicPath: '/' // 资源路径前缀
}

配置开发服务器

本地开发时不需要每次都编译完Webpack再访问,通过webpack-dev-server,我们可以边开发变查看效果,文件会实时编译。

devServer: {
        contentBase: './dist', // 开发服务器配置
        hot: true // 热加载
},

配置loader

本例中没有使用ES6进行编程,但是引用了一个非CommonJS的js模块Zepto,传统用法中在HTML页面引入Zepto就会在window下挂载全局对象Zepto。但是在Webpack开发中不建议使用全局变量,否则模块化的优势将受到影响。

通过使用exports-loader和script-loader,我们可以将Zepto包装为CommonJS模块进入导入。

module: {
        rules: [
            {
                test: require.resolve('zepto'),
                loader: 'exports-loader?window.Zepto!script-loader' // 将window.Zepto包装为CommonJS模块
            },
            {
                test: /\.less$/,
                use: [MiniCssPlugin.loader, 'css-loader', 'less-loader']
            },
            {
                test: /\.css$/,
                use: [MiniCssPlugin.loader, 'css-loader']
            },
            {
                test: /\.(png|jpg|gif)$/,
                use: [
                    {
                        loader: 'file-loader',
                        options: {
                            name: 'images/[name].[hash:8].[ext]'
                        }
                    }
                ]
            },
            {
                test: /\.(htm|html)$/i,
                loader: 'html-withimg-loader'
            }
        ]
    },

配置optimization

主要进行公共模块的打包配置。

optimization: {
        splitChunks: {
            cacheGroups: {
                vendor: {
                    name: "vendor",
                    test: /[\\/]node_modules[\\/]/,
                    chunks: 'all',
                    priority: 10, // 优先级
                },
                styles: {
                    name: 'styles',
                    test: /\.css$/,
                    chunks: 'all'
                }
            }
        }
    },

配置plugin

plugins: [
        new CleanWebpackPlugin(), // 清理发布目录
        new HtmlWebpackPlugin({
            chunks: ['home', 'vendor', 'styles'], // 声明本页面使用到的模块,有主页,公共JS以及公共CSS
            filename: 'index.html', // 输出路径,这里直接输出到dist的根目录,也就是dist/index.html
            template: './src/home/index.html', // HTML模板文件路径
            minify: { 
                removeComments: true, // 移除注释
                collapseWhitespace: true // 合并空格
            }
        }),
        new HtmlWebpackPlugin({
            chunks: ['about', 'vendor', 'styles'],
            filename: 'about/index.html', // 输出到dist/about/index.html
            template: './src/about/index.html',
            minify: {
                removeComments: true,
                collapseWhitespace: true
            }
        }),
        new MiniCssPlugin({
            filename: 'styles/[name].[contenthash:8].css',
            chunkFilename: 'styles/[name].[contenthash:8].css'
        }),
        new webpack.NamedModulesPlugin(), // 热加载使用
        new webpack.HotModuleReplacementPlugin() // 热加载使用
    ]

示例代码

部分示例代码如下:

// src/about/index.js
const $ = require('zepto');
require('normalize.css');
require('../common/style.less');
require('./style.less');

$('#about').on('click', function () {
    alert('点击了about按钮');
});

和传统的JS有点不太一样,多了一些css的require,前面说过,webpack把所有资源当做JS模块,因此这是推荐的做法。

<!--首页-->
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>首页</title>
</head>

<body>
    <ul>
        <li><a href="/">首页</a> </li>
        <li><a href="/about">关于</a></li>
    </ul>
    <div class="logo"></div>
    <button id="home">首页按钮</button>
</body>

</html>

页面中不再需要编写JS。

注意:html中使用<img />标签导入图片的编译,目前还没有好的解决办法,可以通过css background的形式进行处理

开发模式

开发模式下直接启用webpack-dev-server即可,会自动加载工作目录下的webpack.config.js

// package.json
"scripts": {
    "build": "webpack",
    "dev": "webpack-dev-server"
}
npm run dev

生产模式

生产模式下使用webpack编译,编译完成后输出最终文件。

npm run build

输出效果

├── about
│   └── index.html
├── images
│   └── logo.b15c113a.png
├── index.html
├── scripts
│   ├── about.3fb4aa0f.js
│   ├── home.3fb4aa0f.js
│   └── vendor:ed5b7d31.js
└── styles
    ├── about.71eb65e9.css
    ├── home.cd2738e6.css
    └── vendor.9df34e21.css

项目地址

项目已经托管到github,有需要的读者可以自取。

https://github.com/xialeistudio/webpack-multipage-example

0.jpeg

查看原文

赞 35 收藏 27 评论 1

weiwei 赞了文章 · 2019-12-19

如何处理 Web 图片优化?

未优化的图片是影响网站性能的主要因素之一,尤其会影响初次加载。取决于图像的分辨率和画质,图片可能占据整个网站流量的 70%.

生产环境出现未优化的图片并显著影响初次加载速度的现象还是挺常见的。缺乏经验的开发者通常没有意识到这一潜在问题,也不了解各种优化图片的工具和方法。

本文的目标是介绍优化 web 图片的主要工具和方法。

计算 JPG 文件尺寸

未压缩图片的尺寸很容易计算,只需将图片的长宽相乘(px 值),再乘以 3 字节(因为 RGB 色彩系统使用 24 个位元)。所得结果除以 1,048,576(1024 * 1024)即得到兆字节。

image_size = (image_width * image_height * 3) / 1048576

比如,计算分辨率为 1366px x 768px 的未压缩图片的大小:

1366 * 768 * 3 / 1048576 = 3Mb

现在网站的尺寸平均在 2Mb 和 3Mb 之间,想象一下,一张未压缩的图片就占掉了 80% 的流量。在网速较慢的移动网络上,3Mb 大小的图片要花很久才能加载完毕。如果等待网站加载的用户大部分时间花在等待单张图片加载,那网站会损失不少流量。想想就可怕,是吗?

所以,在保证图片分辨率和画质可接受的前提下,我们可以做什么来优化下图片呢?

在线图片优化

如果你的项目是一个简单的静态网站,只有少量不经常变动(甚至从来不会变动)的图片,那么你可以直接使用在线工具。这些工具使用各种算法压缩图像,效果很不错,对简单项目而言完全够用。

就我个人所知,比较著名的在线工具有:

  • Compressor.io,支持 JPG、PNG、SVG、GIF,每次上传 1 个文件
  • Squoosh,支持 JPG、PNG、SVG、GIF,每次上传 1 个文件
  • Optimizilla,支持 JPG、PNG,最多每次上传 20 个文件
  • TinyPNG,支持 JPG、PNG,最多每次上传 20 个文件
  • SVGMinify,支持 SVG,每次上传 1 个文件
  • svgomg,支持 SVG,每次上传 1 个文件

自动化解决方案

然而,如果你做的是多人协作的复杂项目,使用大量图片,在加入每张图片时都手动操作一下很乏味。同时,还存在由于人为错误或其他因素导致一些图片没有优化的风险。

复杂项目常常使用同样复杂的构建系统,比如 GulpWebpackParcel。配置一下这类构建系统,加入图片优化插件很方便。这样就可以完全自动化图片优化过程,在项目中加入图片后就可以优化它们。

就我所知,最有名的插件是 imagemin,可以作为命令行工具使用,也可以作为构建工具的插件使用:

图片加载优化

我们前面介绍了如何通过压缩图片降低文件尺寸,但不过多改变图片分辨率和影响画质。尽管优化图片后文件尺寸能降低不少,但一次性加载大量优化过的图片(比如电商网站的商品列表页面)还是会影响性能。

懒加载

懒加载也叫按需加载,意思是仅加载当前视图(用户屏幕显示范围)内的图片,不加载其他图片(直到它们出现在当前视图内时才加载)。

只有较新版本的浏览器才支持原生的懒加载特性,不过有许多基于 JavaScript 的方案。

  • 原生懒加载
<img data-original="image.jpg" loading="lazy" alt="Sample image" />
  • 基于 JavaScript 的方案

就我所知,最知名的方案有:

verlok/lazyload
yall.js
Blazy (现在没有维护)

渐进式图片

尽管懒加载在性能方面表现出色,但是用户滚动屏幕后需要盯着空白区域等待图片加载,这样的用户体验不太好。网速慢的情况下,下载图片会非常慢。所以我们还需要渐进式图片。

渐进式图片的意思是在高画质图像加载完之前会先显示低画质版本。低画质版本由于画质低、压缩率高,尺寸很小,加载很快。在两者之间我们也可以根据需要显示不同画质的版本。

类似于先加载页面的骨架,渐进式图片这一技术让用户产生图片加载变快的印象。用户不再盯着一片空白区域等待事情发生,而能看到图像变得越来越清晰。
渐进式图片有基于 JavaScript 实现的方案:
progressive-image

响应式图片

我们还需要留意使用尺寸合适的图片。

例如,假设图片在桌面浏览器上显示的最大宽度为 1920px,平板上的最大宽度为 1024px,手机上的最大宽度为 568px,那么最简单的方案是使用 1920px 的图片,这样可以满足所有场景。不过,这种情况下,网速慢、网络不稳定的智能手机用户需要等很久图片才能加载完毕,这就又碰到了我们文章开头提到的问题。

好在我们可以通过 picture 元素告诉浏览器基于媒体查询下载相应的图片。尽管现在 93% 的用户使用的浏览器都支持这一特性,但是这个元素内部还是包含了一个 img 元素,以兼容不支持这一特性的浏览器。

<picture>  <source media="(min-width: 1025px)" srcset="image_desktop.jpg">  <source media="(min-width: 769px)" srcset="image_tablet.jpg">  <img data-original="image_mobile.jpg" alt="Sample image"></picture>

使用 CDN

Cloudinary、Cloudflare 之类的 CDN 服务可以在服务器上优化图片,将优化后的图片传送给用户。如果你的站点使用 CDN,可以看下静态资源优化选项。这样我们就不用操心图片优化,由 CDN 在服务端完成优化。我们只需要操心懒加载、渐进式图片等前端的加载方案。

WebP 图像格式

WebP 是由 Google 开发的专为 web 优化的图像格式。根据 canIUse 的数据,大部分用户使用的浏览器支持 WebP 格式。另外使用 picture 元素也可以很方便地兼容不支持 WebP 的浏览器。

<picture>  <source type="image/webp" srcset="image.webp" />  <source srcset="image.jpg" />  <img data-original="image.jpg" alt="Sample image" /></picture>

有很多在线文件格式转换工具可以把图片转为 WebP 格式,不过 CDN 服务可以在服务端完成这一格式转化。

为高分屏优化

考虑高分屏很有必要,不过这个更多的是用户体验优化。

例如,假定我们在 768px 的屏幕上显示一张 768px x 320px 的图片。但是屏幕有 2x 的密度,也就是说屏幕宽度实际是 2 x 768 = 1536 px。这就意味着我们将 768 px 的图片拉升到 1536 px,这就导致高分屏上的图片看起来很模糊。

为了解决这一问题,我们需要提供为高分屏优化的图片。我们需要单独创建相当于普通屏幕 2 倍或 3 倍分辨率的图片,然后在 srcset 属性上使用 2x 标签表明这是为高分屏准备的图片。

<img data-original="image-1x.jpg" srcset="image-2x.jpg 2x" alt="Sample image" />

例子

支持高分屏的响应式 WebP/PNG 图片:

<picture>    <source srcset="./images/webp/hero-image-420-min.webp 1x, ./images/webp/hero-image-760-min.webp 2x" type="image/webp" media="(max-width: 440px)">    <source srcset="./images/minified/hero-image-420-min.png 1x, ./images/minified/hero-image-760-min.png 2x" media="(max-width: 440px)">    <source srcset="./images/webp/hero-image-550-min.webp 1x, ./images/webp/hero-image-960-min.webp 2x" type="image/webp" media="(max-width: 767px)">    <source srcset="./images/minified/hero-image-550-min.png 1x, ./images/minified/hero-image-960-min.png 2x" media="(max-width: 767px)">    <source srcset="./images/webp/hero-image-420-min.webp 1x, ./images/webp/hero-image-760-min.webp 2x" type="image/webp" media="(max-width: 1023px)">    <source srcset="./images/minified/hero-image-420-min.png 1x, ./images/minified/hero-image-760-min.png 2x" media="(max-width: 1023px)">    <source srcset="./images/webp/hero-image-760-min.webp 1x, ./images/webp/hero-image-960-min.webp 2x" type="image/webp" media="(max-width: 1919px)">    <source srcset="./images/minified/hero-image-760-min.png 1x, ./images/minified/hero-image-960-min.png 2x" media="(max-width: 1919px)">    <source srcset="./images/webp/hero-image-960-min.webp" type="image/webp">    <source srcset="./images/minified/hero-image-960-min.png">    <img  data-original="./images/minified/hero-image-960-min.png" alt="Example"></picture>

结语 —— 优化优先级

  1. 使用优化后的图片(使用自动构建工具、在线服务、CDN 优化)
  2. 使用懒加载(在浏览器有更好的原生支持前考虑使用 JS 方案)
  3. 为高分屏优化图片
  4. 使用 WebP 格式
  5. 使用渐进式图片

可选: 如果条件允许,记得使用 CDN 加速图片(和其他静态资源)。

内容经授权转载自 New Frontend 网站。

查看原文

赞 115 收藏 82 评论 8

weiwei 回答了问题 · 2019-12-18

typescript 闭包类型推导问题 (源自koa-compose)

我帮你去翻了它的types的代码,一时我也没看懂,贴出来你参考参考,不到50行

import * as Koa from "koa";

declare function compose<T1, U1, T2, U2>(
    middleware: [Koa.Middleware<T1, U1>, Koa.Middleware<T2, U2>]
): Koa.Middleware<T1 & T2, U1 & U2>;

declare function compose<T1, U1, T2, U2, T3, U3>(
    middleware: [Koa.Middleware<T1, U1>, Koa.Middleware<T2, U2>, Koa.Middleware<T3, U3>]
): Koa.Middleware<T1 & T2 & T3, U1 & U2 & U3>;

declare function compose<T1, U1, T2, U2, T3, U3, T4, U4>(
    middleware: [Koa.Middleware<T1, U1>, Koa.Middleware<T2, U2>, Koa.Middleware<T3, U3>, Koa.Middleware<T4, U4>]
): Koa.Middleware<T1 & T2 & T3 & T4, U1 & U2 & U3 & U4>;

declare function compose<T1, U1, T2, U2, T3, U3, T4, U4, T5, U5>(
    middleware: [
        Koa.Middleware<T1, U1>, Koa.Middleware<T2, U2>, Koa.Middleware<T3, U3>, Koa.Middleware<T4, U4>,
        Koa.Middleware<T5, U5>
    ]
): Koa.Middleware<T1 & T2 & T3 & T4 & T5, U1 & U2 & U3 & U4 & U5>;

declare function compose<T1, U1, T2, U2, T3, U3, T4, U4, T5, U5, T6, U6>(
    middleware: [
        Koa.Middleware<T1, U1>, Koa.Middleware<T2, U2>, Koa.Middleware<T3, U3>, Koa.Middleware<T4, U4>,
        Koa.Middleware<T5, U5>, Koa.Middleware<T6, U6>
    ]
): Koa.Middleware<T1 & T2 & T3 & T4 & T5 & T6, U1 & U2 & U3 & U4 & U5 & U6>;

declare function compose<T1, U1, T2, U2, T3, U3, T4, U4, T5, U5, T6, U6, T7, U7>(
    middleware: [
        Koa.Middleware<T1, U1>, Koa.Middleware<T2, U2>, Koa.Middleware<T3, U3>, Koa.Middleware<T4, U4>,
        Koa.Middleware<T5, U5>, Koa.Middleware<T6, U6>, Koa.Middleware<T7, U7>
    ]
): Koa.Middleware<T1 & T2 & T3 & T4 & T5 & T6 & T7, U1 & U2 & U3 & U4 & U5 & U6 & U7>;

declare function compose<T1, U1, T2, U2, T3, U3, T4, U4, T5, U5, T6, U6, T7, U7, T8, U8>(
    middleware: [
        Koa.Middleware<T1, U1>, Koa.Middleware<T2, U2>, Koa.Middleware<T3, U3>, Koa.Middleware<T4, U4>,
        Koa.Middleware<T5, U5>, Koa.Middleware<T6, U6>, Koa.Middleware<T7, U7>, Koa.Middleware<T8, U8>
    ]
): Koa.Middleware<T1 & T2 & T3 & T4 & T5 & T6 & T7 & T8, U1 & U2 & U3 & U4 & U5 & U6 & U7 & U8>;

declare function compose<T>(middleware: Array<compose.Middleware<T>>): compose.ComposedMiddleware<T>;

declare namespace compose {
    type Middleware<T> = (context: T, next: Koa.Next) => any;
    type ComposedMiddleware<T> = (context: T, next?: Koa.Next) => Promise<void>;
}

export = compose;

关注 2 回答 1

weiwei 赞了文章 · 2019-12-16

手把手教你打造一款轻量级canvas渲染引擎

背景

当我们开发一个canvas应用的时候,出于效率的考量,免不了要选择一个渲染引擎(比如PixiJS)或者更强大一点的游戏引擎(比如Cocos Creator、Layabox)。

渲染引擎通常会有Sprite的概念,一个完整的界面会由很多的Sprite组成,如果编写复杂一点的界面,代码里面会充斥创建精灵设置精灵位置和样式的“重复代码”,最终我们得到了极致的渲染性能却牺牲了代码的可读性。

游戏引擎通常会有配套的IDE,界面通过拖拽即可生成,最终导出场景配置文件,这大大方便了UI开发,但是游戏引擎一般都很庞大,有时候我们仅仅想开发个好友排行榜。

基于以上分析,如果有一款渲染引擎,既能用配置文件的方式来表达界面,又可以做到轻量级,将会大大满足我们开发轻量级canvas应用的场景。

本文会详细介绍开发一款可配置化轻量级渲染引擎需要哪些事情,代码开源至Github:https://github.com/wechat-miniprogram/minigame-canvas-engine

配置化分析

我们首先期望页面可配置化,来参考下Cocos Creator的实现:对于一个场景,在IDE里面一顿操作,最后场景配置文件大致长下面的样子:

  // 此处省略n个节点
  {
    "__type__": "cc.Scene",
    "_opacity": 255,
    "_color": {
      "__type__": "cc.Color",
      "r": 255,
      "g": 255,
      "b": 255,
      "a": 255
    },
    "_parent": null,
    "_children": [
      {
        "__id__": 2
      }
    ],
  },

在一个JSON配置文件里面,同时包含了节点的层级结构样式,引擎拿到配置文件后递归生成节点树然后渲染即可。PixiJS虽然只是个渲染引擎,但同样可以和cocos2d一样做一个IDE去拖拽生成UI,然后写一个解析器,声称自己是PixiJS Creator😬。

这个方案很好,但缺点是每个引擎有一套自己的配置规则,没法做到通用化,而且在没有IDE的情况下,手写配置文件也会显得反人类,我们还需要更加通用一点的配置。

寻找更优方案

游戏引擎的配置方案如果要用起来主要有两个问题:

  1. 手写可读性差,特别是对于层级深的节点树;
  2. 样式和节点树没有分离,配置文件冗余;
  3. 配置不通用;

对于高可读性样式分离,我们惊讶的发现,这不就是Web开发的套路么,编写HTML、CSS丢给浏览器,界面就出来了,省时省力。

new.jpeg

如此优秀的使用姿势,我们要寻求方案在canvas里面实现一次!

实现分析

结果预览

在逐步分析实现方案之前,我们先抛个最终实现,编写XML和样式,就可以得到结果:

let template = `
<view id="container">
  <text id="testText" class="redText" value="hello canvas"> </text>
</view>
`;

let style = {
    container: {
         width: 200,
         height: 100,
         backgroundColor: '#ffffff',
         justContent: 'center',
         alignItems: 'center',
     },
     testText: {
         color: '#ff0000',
         width: 200,
         height: 100,
         lineHeight: 100,
         fontSize: 30,
         textAlign: 'center',
     }
}
// 初始化渲染引擎
Layout.init(template, style);
// 执行真正的渲染
Layout.layout(canvasContext);

方案总览

既然要参考浏览器的实现,我们不妨先看看浏览器是怎么做的:
render-tree-construction.png
如上图所示,浏览器从构建到渲染界面大致要经过下面几步:

  • HTML 标记转换成文档对象模型 (DOM);CSS 标记转换成 CSS 对象模型 (CSSOM)
  • DOM 树与 CSSOM 树合并后形成渲染树。
  • 渲染树只包含渲染网页所需的节点。
  • 布局计算每个对象的精确位置和大小。
  • 最后一步是绘制,使用最终渲染树将像素渲染到屏幕上。

在canvas里面要实现将HTML+CSS绘制到canvas上面,上面的步骤缺一不可。

构建布局树和渲染树

上面的方案总览又分两大块,第一是渲染之前的各种解析计算,第二是渲染本身以及渲染之后的后续工作,先看看渲染之前需要做的事情。

解析XML和构建CSSOM

首先是将HTML(这里我们采用XML)字符串解析成节点树,等价于浏览器里面的“HTML 标记转换成文档对象模型 (DOM)”,在npm搜索xml parser),可以得到很多优秀的实现,这里我们只追求两点:

  1. 轻量:大部分库为了功能强大动辄几百k,而我们只需要最核心的xml解析成JSON对象;
  2. 高性能:在游戏里面不可避免有长列表滚动的场景,这时候XML会很大,要尽量控制XML解析时间;

综合以上考量,选择了fast-xml-parser,但是仍然做了一些阉割和改造,最终模板经过解析会得到下面的JSON对象

{
    "name":"view",
    "attr":{
        "id":"container"
    },
    "children":[
        {
            "name":"text",
            "attr":{
                "id":"testText",
                "class":"redText",
                "value":"hello canvas"
            },
            "children":[

            ]
        }
    ]
}

接下来是构建CSSOM,为了减少解析步骤,我们手工构建一个JSON对象,key的名字为节点的id或者class,以此和XML节点形成绑定关系:

let style = {
    container: {
         width: 200,
         height: 100
     },
}

DOM 树与 CSSOM 树合并后形成渲染树

DOM树和CSSOM构建完成后,他们仍是独立的两部分,需要将他们构建成renderTree,由于style的key和XML的节点有关联,这里简单写个递归处理函数就可以实现:该函数接收两个参数,第一个参数为经过XML解析器解析吼的节点树,第二个参数为style对象,等价于DOM和CSSOM。

// 记录每一个标签应该用什么类来处理
const constructorMap = {
    view      : View,
    text      : Text,
    image     : Image,
    scrollview: ScrollView,
}
const create = function (node, style) {
    const _constructor = constructorMap[node.name];

    let children = node.children || [];

    let attr = node.attr || {};
    const id = attr.id || '';
    // 实例化标签需要的参数,主要为收集样式和属性
    const args = Object.keys(attr)
        .reduce((obj, key) => {
            const value = attr[key]
            const attribute = key;

            if (key === 'id' ) {
                obj.style = Object.assign(obj.style || {}, style[id] || {})
                return obj
            }

            if (key === 'class') {
                obj.style = value.split(/\s+/).reduce((res, oneClass) => {
                return Object.assign(res, style[oneClass])
                }, obj.style || {})

                return obj
            }
            
            if (value === 'true') {
                obj[attribute] = true
            } else if (value === 'false') {
                obj[attribute] = false
            } else {
                obj[attribute] = value
            }

            return obj;
        }, {})

    // 用于后续元素查询
    args.idName    = id;
    args.className = attr.class || '';

    const element  = new _constructor(args)
    element.root = this;
    
    // 递归处理
    children.forEach(childNode => {
        const childElement = create.call(this, childNode, style);

        element.add(childElement);
    });

    return element;
}

经过递归解析,构成了一颗节点带有样式的renderTree。

计算布局树

渲染树搞定之后,要着手构建布局树了,每个节点在相互影响之后的位置和大小如何计算是一个很头疼的问题。但仍然不慌,因为我们发现近几年非常火的React Native、weex之类的框架必然会面临同样的问题:

Weex 是使用流行的 Web 开发体验来开发高性能原生应用的框架。
React Native 使用JavaScript和React编写原生移动应用

这些框架也需要将html和css编译成客户端可读的布局树,能否避免重复造轮子将它们的相关模块抽象出来使用呢?起初我以为这部分会很庞大或者和框架强耦合,可喜的是这部分抽象出来仅仅只有1000来行,他就是week和react native早起的布局引擎css-layout。这里有一篇文章分析得非常好,直接引用至,不再赘述:《由 FlexBox 算法强力驱动的 Weex 布局引擎》

npm上面可以搜到css-layout,它对外暴露了computeLayout方法,只需要将上面得到的布局树传给它,经过计算之后,布局树的每个节点都会带上layout属性,它包含了这个节点的位置和尺寸信息!

// create an initial tree of nodes
var nodeTree = {
    "style": {
      "padding": 50
    },
    "children": [
      {
        "style": {
          "padding": 10,
          "alignSelf": "stretch"
        }
      }
    ]
  };
 
// compute the layout
computeLayout(nodeTree);
 
// the layout information is written back to the node tree, with
// each node now having a layout property: 
 
// JSON.stringify(nodeTree, null, 2);
{
  "style": {
    "padding": 50
  },
  "children": [
    {
      "style": {
        "padding": 10,
        "alignSelf": "stretch"
      },
      "layout": {
        "width": 20,
        "height": 20,
        "top": 50,
        "left": 50,
        "right": 50,
        "bottom": 50,
        "direction": "ltr"
      },
      "children": [],
      "lineIndex": 0
    }
  ],
  "layout": {
    "width": 120,
    "height": 120,
    "top": 0,
    "left": 0,
    "right": 0,
    "bottom": 0,
    "direction": "ltr"
  }
}

这里需要注意的是,css-layout实现的是标准的Flex布局,如果对于CSS或者Flex布局不是很熟悉的同学,可以参照这篇文章进行快速的入门:《Flex 布局教程:语法篇》。再值得一提的是,作为css-layout的使用者,好的习惯是给每个节点都赋予width和height属性😀。

渲染

基础样式渲染

在处理渲染之前,我们先分析下在Web开发中我们重度使用的标签:

标签功能
div通常作为容器使用,容器也可以有一些样式,比如border和背景颜色之类的
img图片标签,向网页中嵌入一幅图像,通常我们会对图片添加borderRadius实现圆形头像
p/span文本标签,用于展示段落或者行内文字

在构建节点树的过程中,对于不同类型的节点会有不同的类去处理,上述三个标签对应了ViewImageText类,每个类都有自己的render函数。

render函数只需要做好一件事情:根据css-layout计算得到的layout属性和节点本身样式相关的style属性,通过canvas API的形式绘制到canvas上;

这件事情听起来工作量很大,但其实也没有这么难,比如下面演示如何处理文本的绘制,实现文本的字体、字号、左对齐右对齐等。

 function renderText() {  
    let style = this.style || {};

    this.fontSize = style.fontSize || 12;
    this.textBaseline = 'top';
    this.font = `${style.fontWeight  || ''} ${style.fontSize || 12}px ${DEFAULT_FONT_FAMILY}`;
    this.textAlign = style.textAlign || 'left';
    this.fillStyle = style.color     || '#000';
    
    if ( style.backgroundColor ) {
        ctx.fillStyle = style.backgroundColor;
        ctx.fillRect(drawX, drawY, box.width, box.height)
    }

    ctx.fillStyle = this.fillStyle;

    if ( this.textAlign === 'center' ) {
        drawX += box.width / 2;
    } else if ( this.textAlign === 'right' ) {
        drawX += box.width;
    }

    if ( style.lineHeight ) {
        ctx.textBaseline = 'middle';
        drawY += style.lineHeight / 2;
    }
}

但这件事情又没有这么简单,因为有些效果你必须层层组合计算才能得出效果,比如borderRadius的实现、文本的textOverflow实现,有兴趣的同学可以看看源码

再者还有更深的兴趣,可以翻翻游戏引擎是怎么处理的,结果功能过于强大之后,一个Text类就有1000多行:LayaAir的Text实现😯。

重排和重绘

当界面渲染完成,我们总不希望界面只是静态的,而是可以处理一些点击事件,比如点击按钮隐藏一部分元素,亦或是改变按钮的颜色之类的。

在浏览器里面,有对应的概念叫重排和重绘:

引自文章:《网页性能管理详解》

网页生成的时候,至少会渲染一次。用户访问的过程中,还会不断重新渲染。重新渲染,就需要重新生成布局和重新绘制。前者叫做"重排"(reflow),后者叫做"重绘"(repaint)。

那么哪些操作会触发重排,哪些操作会触发重绘呢?这里有个很简单粗暴的规则:只要涉及位置和尺寸修改的,必定要触发重排,比如修改width和height属性,在一个容器内做和尺寸位置无关的修改,只需要触发局部重绘,比如修改图片的链接、更改文字的内容(文字的尺寸位置固定),更具体的可以查看这个网站csstriggers.com

在我们这个渲染引擎里,如果执行触发重排的操作,需要将解析和渲染完整执行一遍,具体来讲是修改了xml节点或者与重排相关的样式之后,重复执行初始化和渲染的操作,重排的时间依赖节点的复杂度,主要是XML节点的复杂度。

// 该操作需要重排以实现界面刷新
style.container.width = 300;
// 重排前的清理逻辑
Layout.clear();
// 完整的初始化和渲染流程
Layout.init(template, style);
Layout.layout(canvasContext);

对于重绘的操作,暂时提供了动态修改图片链接和文字的功能,原理也很简单:通过Object.defineProperty,当修改布局树节点的属性时,抛出repaint事件,重绘函数就会局部刷新界面。

Object.defineProperty(this, "value", {
    get : function() {
        return this.valuesrc;
    },
    set : function(newValue){
        if ( newValue !== this.valuesrc) {
            this.valuesrc = newValue;
            // 抛出重绘事件,在回调函数里面在canvas的局部擦除layoutBox区域然后重新绘制文案
            this.emit('repaint');
        }
    },
    enumerable   : true,
    configurable : true
});

那怎么调用重绘操作呢?引擎只接收XML和style就绘制出了页面,要想针对单个元素执行操作还需要提供查询接口,这时候布局树再次排上用场。在生成renderTree的过程中,为了匹配样式,需要通过id或者class来形成映射关系,节点也顺带保留了id和class属性,通过遍历节点,就可以实现查询API:

function _getElementsById(tree, list = [], id) {
    Object.keys(tree.children).forEach(key => {
        const child = tree.children[key];

        if ( child.idName === id ) {
            list.push(child);
        }

        if ( Object.keys(child.children).length ) {
            _getElementsById(child, list, id);
        }
    });

    return list;
}

此时,可以通过查询API来实现实现重绘逻辑,该操作的耗时可以忽略不计。

let img = Layout.getElementsById('testimgid')[0];
img.src = 'newimgsrc';

事件实现

查询到节点之后,自然是希望可以绑定事件,事件的需求很简单,可以监听元素的触摸和点击事件以执行一些回调逻辑,比如点击按钮换颜色之类的。

我们先来看看浏览器里面的事件捕获和事件冒泡机制:

引自文章《JS中的事件捕获和事件冒泡》
捕获型事件(event capturing):事件从最不精确的对象(document 对象)开始触发,然后到最精确(也可以在窗口级别捕获事件,不过必须由开发人员特别指定)。
冒泡型事件:事件按照从最特定的事件目标到最不特定的事件目标(document对象)的顺序触发。

1576225959480.jpg

前提:每个节点都存在事件监听器on和发射器emit;每个节点都有个属性layoutBox,它表明了元素的在canvas上的盒子模型:

layoutBox: {
    x: 0,
    y: 0,
    width: 100,
    height: 100
}

canvas要实现事件处理与浏览器并无不同,核心在于:给定坐标点,遍历节点树的盒子模型,找到层级最深的包围该坐标的节点。
image.png

当点击事件发生在canvas上,可以拿到触摸点的x坐标和y坐标,该坐标位于根节点的layoutBox内,当根节点仍然有子节点,对子节点进行遍历,如果某个子节点的layoutBox仍然包含了该坐标,再次重复执行以上步骤,直到包含该坐标的节点再无子节点,这个过程称之为事件捕获

// 给定根节点树和触摸点的位置通过递归即可实现事件捕获
function getChildByPos(tree, x, y) {
    let list = Object.keys(tree.children);

    for ( let i = 0; i < list.length;i++ ) {
        const child = tree.children[list[i]];
        const box   = child.realLayoutBox;

        if (   ( box.realX <= x && x <= box.realX + box.width  )
            && ( box.realY <= y && y <= box.realY + box.height ) ) {
            if ( Object.keys(child.children).length ) {
                return getChildByPos(child, x, y);
            } else {
                return child;
            }
        }
    }

    return tree;
}

层级最深的节点被找到之后,调用emit接口触发该节点的ontouchstart事件,如果事先有对ontouchstart进行监听,事件回调得以触发。那么怎么实现事件冒泡呢?在事件捕获阶段我们并没有记录捕获的链条。这时候布局树的优势又体现出来了,每个节点都保存了自己的父节点和子节点信息,子节点emit事件之后,同时调用父节点的emit接口抛出ontouchstart事件,而父节点又继续对它自己的父节点执行同样的操作,直至根节点,这个过程称之为事件冒泡

// 事件冒泡逻辑
['touchstart', 'touchmove', 'touchcancel', 'touchend', 'click'].forEach((eventName) => {
    this.on(eventName, (e, touchMsg) => {
        this.parent && this.parent.emit(eventName, e, touchMsg);
    });
});

滚动列表实现

屏幕区域内,展示的内容是有限的,而浏览器的页面通常都很长,可以滚动。这里我们实现scrollview,如果标签内子节点的总高度大于scrollview的高度,就可以实现滚动。

1.对于在容器scrollview内的所有一级子元素,计算高度之合;

function getScrollHeight() {
    let ids  = Object.keys(this.children);
    let last = this.children[ids[ids.length - 1]];

    return last.layoutBox.top + last.layoutBox.height;
}

2.设定分页大小,假设每页的高度为2000,根据上面计算得到的ScrollHeight,就可以当前滚动列表总共需要几页,为他们分别创建用于展示分页数据的canvas:

this.pageCount = Math.ceil((this.scrollHeight + this.layoutBox.absoluteY) / this.pageHeight);

3.递归遍历scrollview的节点树,通过每个元素的absoluteY值判断应该坐落在哪个分页上,这里需要注意的是,有些子节点会同时坐落在两个分页上面,在两个分页都需要绘制一遍,特别是图片类这种异步加载然后渲染的节点

function renderChildren(tree) {
    const children = tree.children;
    const height   = this.pageHeight;

    Object.keys(children).forEach( id => {
        const child   = children[id];
        let originY   = child.layoutBox.originalAbsoluteY;
        let pageIndex = Math.floor(originY / height);
        let nextPage  = pageIndex + 1;

        child.layoutBox.absoluteY -= this.pageHeight * (pageIndex);

        // 对于跨界的元素,两边都绘制下
        if ( originY + child.layoutBox.height > height * nextPage ) {
            let tmpBox = Object.assign({}, child.layoutBox);
            tmpBox.absoluteY = originY - this.pageHeight * nextPage;

            if ( child.checkNeedRender() ) {
                this.canvasMap[nextPage].elements.push({
                    element: child, box: tmpBox
                });
            }
        }

        this.renderChildren(child);
        });
    }

4.将scrollview理解成游戏里面的Camera,只把能拍摄到的区域展示出来,那么所有的分页数据从上而下拼接起来就是游戏场景,在列表滚动过程中,只“拍摄”尺寸为scrollviewWidth*scrollViewHeight的区域,就实现了滚动效果。拍摄听起来很高级,在这里其实就是通过drawImage实现就好了:

// ctx为scrollview所在的canvas,canvas为分页canvas
this.ctx.drawImage(
    canvas,
    box.absoluteX, clipY, box.width, clipH,
    box.absoluteX, renderY, box.width, clipH,
);

5.当scrollview上触发了触摸事件,会改变scrollview的top属性值,按照步骤4不断根据top去裁剪可视区域,就实现了滚动。

上述方案为空间换时间方案,也就是在每次重绘过程中,因为内容已经绘制到分页canvas上了(这里可能会比较占空间),每次重绘,渲染时间得到了最大优化。

其他

至此,一个类浏览器的轻量级canvas渲染引擎出具模型:

  1. 给定XML+style对象可以渲染界面;
  2. 支持一些特定的标签:view、text、image和scrollview;
  3. 支持查询节点反向修改节点属性和样式;
  4. 支持事件绑定;

文章篇幅有限,很多细节和难点仍然没法详细描述,比如内存管理(内存管理不当很容易内存持续增涨导致应用crash)、scrollview的滚动事件实现细节、对象池使用等。有兴趣的可以看看源码:https://github.com/wechat-miniprogram/minigame-canvas-engine/tree/master/src
下图再补一个滚动好友排行列表demo:
screenshot.gif

调试及应用场景

作为一个完整的引擎,没有IDE怎么行?这里为了提高UI调试的效率(实际上很多时候游戏引擎的工作流很长,调试UI,改个文案之类的是个很麻烦的事情),提供一个简版的在线调试器,调UI是完全够用了:https://wechat-miniprogram.github.io/minigame-canvas-engine/
image.png

最后要问,费了这么大劲搞了个渲染引擎有什么应用场景呢?当然是有的:

  1. 跨游戏引擎的游戏周边插件:很有游戏周边功能比如签到礼包、公告页面等都是偏H5页面的周边系统,如果通过本渲染引擎渲染到离屏canvas,每个游戏引擎都将离屏canvas当成普通精灵渲染即可实现跨游戏引擎插件;
  2. 极致的代码包追求:如果你对微信小游戏有所了解,就会发现现阶段在开放数据域要绘制UI,如果不想裸写UI,就得再引入一份游戏引擎,这对代码包体积影响是很大的,而大部分时候仅仅是想绘制个好友排行榜;
  3. 屏幕截图:这点在普通和H5和游戏里面都比较常见,将一些用户昵称和文案之类的与背景图合并成为截图,这里可以轻松实现。
  4. 等等等......

参考资料

1.由 FlexBox 算法强力驱动的 Weex 布局引擎:https://www.jianshu.com/p/d085032d4788
2.网页性能管理详解:https://www.ruanyifeng.com/blog/2015/09/web-page-performance-in-depth.html
3.渲染性能:https://developers.google.cn/web/fundamentals/performance/rendering
4.简化绘制的复杂度、减小绘制区域:https://developers.google.com/web/fundamentals/performance/rendering/simplify-paint-complexity-and-reduce-paint-areas?hl=zh-CN

查看原文

赞 57 收藏 36 评论 8

weiwei 赞了文章 · 2019-12-16

Docker 及 GitLab CI 在前端工作流上的实践分享(二)

上一篇讲了 Docker 的使用,这篇同样通过一个简单示例,来讲讲 GitLab CI

一、什么是 GitLab CI ?

gitlab-ci 全称是 gitlab continuous integration,也就是基于 gitlab 的持续集成工具。中心思想是当每一次
push到gitlab的时候,都会触发一次脚本执行,然后脚本的内容包括了测试,编译,部署等一系列自定义的内容。
高版本的 GitLab 自带了 GitLab CI,所以不需要另外安装。

二、什么是 GitLab-Runner ?

GitLab-Runner 是脚本执行的承载者,GitLab-CI 事先注册好 GitLab-Runner,再 push 代码,对应的 Runner 就会执行你所定义的脚本。

三、安装 GitLab-Runner

Gitlab Runner安装方式有两种,一种是直接二进制文件安装,一种是基于docker镜像安装。

二进制文件安装

[1] 下载对应操作系统的二进制包,我这里使用的是mac版本,

sudo curl --output /usr/local/bin/gitlab-ci-multi-runner https://gitlab-ci-multi-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-ci-multi-runner-darwin-amd64

[2] 给 gitlab-ci-multi-runner 设置权限

sudo chmod +x /usr/local/bin/gitlab-ci-multi-runner

以上是官方安装文档,如果有问题,可以手动到版本下载列表下载对应的版本,然后复制到/usr/local/bin/目录下 ---- 反正我是自己下载安装才能用的,泪目 T T

[3] 注册runner
首先,进入到你的 gitlab 项目网页,找到 Settings -> Pipelines,然后找到对应的 urltoken

然后在终端输入

gitlab-ci-multi-runner register

然后刷新你的网页,会看到

说明注册成功了。

另外,我们可以通过 gitlab-ci-multi-runner list 查询你注册的runner ,用 gitlab-ci-multi-runner status 查看 runner 服务是否运行中。

docker镜像安装

[1] 先获取 gitlab-runner 镜像

sudo docker pull gitlab/gitlab-runner:latest

[2] 启动 gitlab-runner container

sudo docker run -d --name gitlab-runner --restart always \
  -v /srv/gitlab-runner/config:/etc/gitlab-runner \
  -v /var/run/docker.sock:/var/run/docker.sock \
  gitlab/gitlab-runner:latest

[3] 注册runner

sudo docker exec -it gitlab-runner gitlab-ci-multi-runner register

注册过程略,方式同方式一步骤3.

四、配置.gitlab-ci.yml

GitLab CI的一切工作,都是由 .gitlab-ci.yml 来配置的。详细文档可以参考这里

首先,在项目根目录下创建 .gitlab-ci.yml 文件(编辑完要提交到g itlab 才能生效):

#定义 stages,用来定义工作阶段,多个 stages 会按顺序进行
stages:
  - build
  - deploy_test
  - deploy_production

# 设置缓存 
cache:
  paths:
    - node_modules/
    - dist/

# 安装依赖 before_script 会在每个 stages 执行之前运行
before_script:
- npm install

# 编译 这里对应上方 stages ,
build:
  stage: build 
  script:    # script 为要执行的命令,可以多条按顺序执行
    - npm run build

# 部署测试服务器 
deploy_test:
  stage: deploy_test
  only:    # only 定义触发分支,即只有在dev分支提交是  才执行以下命令
    - dev
  script:
    - bash scripts/dev.sh


# 部署生产服务器
deploy_production:
  stage: deploy_production
  only:
    - master
  script:
    - bash scripts/deploy.sh

配置完成后,当你在项目 push 代码到 gitlab 的时候,就会触发 gitlab-ci,然后执行你定义的代码。
可以在

running 表示正在运行,passed 表示通过了。
ps:有个容易遇到的坑,当你卡在 pending 不动的时候,可以看看你的 runner 是否设置了 '无 tag 标签也运行'
回到你的 runner,点编辑

然后,勾选第二项 Run untagged jobs

运行日志可以在这里查看

那么到这,GitLab CI 的基本使用,已经完成啦,赶快去试一下吧 :)

参考资料:
gitlab-runner 安装
gitlab ci yaml 配置
【后端】gitlab之gitlab-ci自动部署

查看原文

赞 13 收藏 15 评论 5

weiwei 赞了文章 · 2019-12-13

如果你是新晋的leader, 你可能需要了解这些。

“manager”的图片搜索结果

背景

在职业发展的道路上,我们总会面临这样的抉择:

  • 是在技术的路上一条路走到黑,做技术专家
  • 接触管理, 走上管理
  • 年龄大了,搬砖没人要,转型 or 去公司楼下卖炒粉

我曾经有个小小的愿望: 在毕业5年内,能做点管理相关的东西。

这个愿望,在我毕业的第四年实现了。

说起来也我是机缘巧合,遇到了贵人,加上我自己的努力, 最后得到了这样的机会。

说实话, 我有时候时常怀疑自己不适合写代码。 翻阅之前的代码的时候, 经常会出现, “这TM谁写的什么破代码”

最后一看记录,不好意思,是我写的。

曾经也以为, 管理什么的, 都是虚的,没什么大不了的,领导谁不会当啊。

可是, 毕业之后经历过的几任leader, 差别真的很大,除去人格魅力的差异, 管理水平也是很大的不同。

领导, 真的不一样。

上周(2019.12.06), 我参加了公司的管理一期培训,短短两天的课程,初接触了正规的管理培训,收获很大。

理论很枯燥, 讲师很专业。

讲师通过一个个生动的案例和现场互动,把管理相关的知识传授给我们,并有一对一的互动去巩固。

今天的这篇, 是我根据上课的笔记和材料整理出来的,文字居多, 少量图片辅助。

内容都是实实在在的, 可能略显枯燥,为了便于理解, 我做了个脑图, 便于记忆, 希望能给大家一些帮助和启发。

正文

新任 Manager ,初步需要了解这几个方面:

  1. 成长引擎
  2. 绩效达成
  3. 跟踪反馈
  4. 共赢思维
  5. 向上管理
  6. 时间管理

如图所示:

image.png

下面我会分别介绍这几个方面, 可以先看一下总揽:

Manager 成长地图.png

高清原图:
https://raw.githubusercontent.com/beMySun/react-hooks-todoList/test/public/Manager%20%E6%88%90%E9%95%BF%E5%9C%B0%E5%9B%BE.png
这张图几乎覆盖了本文的全部内容, 花了两天时间整理制作的,可以仔细看看。

正文部分是文字版。

Manager 成长地图

1. 成长引擎

认知角色

角色认知,是Manager成功转型的基础

主要是两个方面:

  1. 接纳变化
  2. 认知自我

Manager 上任后必须勇于接纳并拥抱变化

image.png

  1. 人际关系网
  2. 组织期待
  3. 工作方式
  4. 工作重点

在不同发展阶段, 管理者面临不同的转型挑战

image.png

  1. 工作理念

    • 信念价值观非常重要, 让工作聚集

      • 通过他人完成工作

        当人们被提升时, 他们常常认为自己有成功的把握,作为业绩出色的员工,他们的努力得到了认可和回报。实际上, 第一次担任Manager 要想获得成功需要一个重大的转变。 即: 他们的工作不再需要自己亲自去获得,而是通过下属和团队的努力去获得。--- Ram Charan 哈佛教授
  2. 领导技能

    胜任新工作所需要的新能力

    • 角色转变后需要具备的核心职责能力

      1. 界定和布置任务
      2. 员工赋能
      3. 关系建设
      
  3. 时间管理

    新的时间分配结构, 决定如何工作。

    • 你的时间在哪里, 你的成果就在哪里!

认知自我

认知自我的目的

  1. 了解自我
  2. 认可不同
  3. 快速成长

认知自我的方法

  1. 主动寻求反馈
  2. 虚心接纳反馈
  3. 对他人表示感谢
  4. 作出行动计划
特别要注意的是,在寻求反馈的之后, 不要试图做任何的解释。 当你开始解释的时候,别人的反馈便会停止

构筑信任

image.png

1. 信任的红利

  • 效率提高, 成本降低

2.自我的信任

  1. 诚实:言行一致
  2. 动机:端正目的
  3. 能力:解决问题
  4. 成果:带来结果

3. 信任他人

  • 得到别人的信任很难, 失去别人的信任很简单。

4. 5个方法快速建立信任

  1. 保持透明和一致

    定期,公开地分享信息,让大家了解并从中得到帮助。 否则, 他们会认为你一定隐藏了什么。

  2. 讲述真实的, 有关的故事

    分享你如何处理艰难时刻地真实故事, 会让大家理解和欣赏你,更容易站在你的角度看问题。

  3. 欣赏“个人主义”

    学会欣赏别人的个性,不要试图去改变它。 让大家了解真实的你,也愿意接受真实的你。

  4. 给别人一个预览

    描述一个积极的,但是现实的远景/梦想, 会减少大家对“不确定”的恐惧与猜想, 并让他们为应对前行的障碍做好准备。

  5. 帮助别人成功

    尽早授权,用这样的办法来帮助大家成功, 他们会首先信任自己,敞开自己。信任你作为他们前进路上的同盟者。

2. 时间管理

image.png

1. 遵循 要素 * 要素 四象限法则

  • 比如: 紧急度 * 重要性

2. GTD 时间管理法

  • GTD = Get Things Done

3. 行动分类, 排除干扰, 专注工作

  • 立刻行动
  • 番茄钟
  • 指派他人处理

3. 绩效达成

image.png

一. 目标设定与共识

  • 关键技术分享: S.M.A.R.T 原则

    • Specific 具体

      如果是一个团队目标,要用两个分值(数字 or 百分比)来设定。

    • Measurable 可衡量
    • Attainable 可实现的
    • Relevant 与目标相关的
    • Time Phased 有时间限制的
  • 目标设定

    • 关键目标要做拆解
  • 目标的书写格式

    • 基本格式

      • 完成时间

        • 在X月X日前
        • 本季度内
        • 年底之前 / 每半年 / 每季度 ...
      • 可评估的结果

        • 业绩数字
        • 营收数字
        • 错误次数
        • 市占率 / 曝光率/ Cycle Time/ ...
      • 动词 (做的对象 / 做什么)

        • 增加
        • 完成
        • 降低 / 提升
        • 达到 / 维持
    • 基本原则

      1. 以动词引出目标, 例如: 增加,降低, 完成
      2. 指出数量, 成本, 速度, 日期 或时间等衡量内容
      3. 避免“全有或全无” 的目标
      4. 目标设定为 5 (+- 2)个
      5. 高阶主管多为结果指标, 执行层多为过程指标。
  > Leader 的一个重要职责就是把`结果指标`分解成可执行的`过程指标`.
  • 目标下达

    • 关键技术分享: 目标沟通5流程

image.png

    1. 开场

      说明理想结果,阐明重要意义

    2. 收集

      数据,事实, 与担忧

    3. 方案

      轮流贡献,解决方案
      
      规则:
      不做评判
      设定时间

    4. 共识

      选择最佳建议,
      具体行动计划。
      
      5W/ 1H
      谁做
      做什么
      何时做
      为什么做
      怎么做

    5. 结束

      会议总结, 引发承诺。

二. 辅导与反馈

  • 关键技术分享: 沟通5方针

    • 方针一: 维护自尊

      • 基本原则

        1. 当工作进展不尽人意时,要维护员工自尊

          • 关注事实
          • 尊重并支持他人
          • 澄清动机
        2. 当员工表现突出, 成功完成了任务或做出贡献时,需要加强员工的自信

          • 对好的观点和想法表示认可
          • 对工作成就表示赞许
          • 表达和展示你的信心
      • 范例

        1. 你主动记录了今天会议中工作组关注的问题, 这对我们会后的执行工作非常有帮助,我对此表示十分感谢。
        2. 由于你们紧密合作而且目标明确,我们最终完成了任务, 很棒!
        3. 你在会议上的发言, 大家都很愿意听, 我觉得蛮有独到见解的。
      • 注意: 区分和还原事实
    • 方针二: 同理回应

      • 基本原则

        • 作为领导者, 会经常面对员工的成功, 失败,自豪, 挫折等不同的感受,当他人想你倾诉感受时, 应同理回应,让员工知道你理解他的感受,以此来:

          1. 排除不良情绪
          2. 对积极乐观的感受做出回应
          3. 对事实和感受做出回应
          
      • 范例

        1. “显然, 最后一刻发生的变动给你和整个团队带来了很多负面的影响。”
        2. 你的表情说明了一切, 我能理解你现在一定对客户满意度调查的结果感到沮丧。
      • 关键点: 用自己的话, 重复对方的话, 说出TA的委屈。

        在这个环节有几点容易犯错的点,比如:

        1. 我很同情你, 但是。。。
        2. (自传式回应)想当年我。。。
        3. (尊重而不认同)我很尊重你, 但是。。。
    • 方针三:探寻他人

      • 基本原则

        • 鼓励参与:当你向员工寻求帮助, 鼓励参与时,员工会感到他们的能力和贡献得到了重视,你需要:

          1. 将鼓励作为首选
          2. 用提问的方式激活他人的思维
          3. 通过员工参与,鼓励其承担责任
      • 范例

        1. 在进一步开展工作前, 你对工作计划的进展有何评价?
        2. 在我给你答案前, 我想先听听你的问题在哪里?
        3. 你需要我怎样的支持呢?
        4. 你想用这个方法的好处是什么?
    • 方针四:分享自我

      • 基本原则

        • 建立信任: 领导者只有得到员工的信任,员工才会为其出色的工作。 实践证明,把你的观点和感受恰当地表达出来,可以建立信任, 你需要记住:

          1. 恰当的表达感受与想法
          2. 切记: 你的想法,意见,和经验,应当作为补充建议提出,不要试图全盘取代别人的想法。
      • 范例

        1. 看起来这个项目的开端很好,我想我们应该讨论 一下要完成这个项目还需要什么,以便达成共识。
        2. 我知道这是个不小的变动,说实话,我也有点担心。对这样的处理给客户带来怎样的影响, 我也没有把握。
        3. 接下来我想详细讲述一下这一过程是如何开始的,以及我们小组为何必须在其中扮演关键角色。
    • 方针五:给予支持

      • 基本原则

        • 树立主人翁意识: 当你告诉员工他们做的不对, 然后亲自动手去做时, 员工的自信心会很快丧失殆尽。 相反,如果你为员工提供支持而不是削减其职责,就会帮助员工树立起该项任务或工作的主人翁意识, 树立信心,你需要:

          1. 帮助他们思考,给予行动上的支持
          2. 客观衡量自己能办到的事情,并恪守承诺
          3. 抵御越俎代庖的诱惑
      • 范例

        • 我知道你对这个程序还不太熟悉, 如果你需要帮助, 我可以为你提供一些辅导。
        • 当然,我也可以给客户打这个电话,但是我认为客户更希望从你这里获取信息。我们可以先商量一下你该如何把这个消息告诉客户,你认为这样会有帮助吗?
  • 反馈的力量

    • 关键技术分享: 反馈中的STAR 与 STARAR

      • STAR 正面反馈

        • S/T: situation/Task 处境或工作任务

          • 是什么问题, 商机,挑战或任务?
        • A: Action 行动

          • 处理这种情况或任务时说了或做了什么,就改进型反馈而言,这个人或团队做了哪些无效的事情?
        • R : Results

          • 由于个人或团队的行为带来了哪些好的或坏的变化? 这些变化导致了哪些影响或结果?
        • 正面反馈举例:

          • S/T : 九月份我们安装了新的呼叫分配系统
          • A: 你在完成每天的电话接听任务后, 还能够继续学习使用新系统, 阅读系统资料,请教技术人员。
          • R: 新系统安装后,你马上就能够操作了, 没有影响客户满地度。
      • STARAR 改进型反馈

        • STAR 同上
        • A: 建议行动
        • R: 强化结果
        • 改进型反馈举例:

          • S/T: 小华, 你和王先生第一次见面后, 这个客户从未购买过我们公司的产品。
          • A: 你就立刻建议他购买我们公司的产品, 提出各种购买选择, 还提供了不少数据, 说话时滔滔不绝。
          • R: 我发现王先生有点不高兴, 好像在逼他做决定。
          • A: 如果你在提供解决方案前, 多花一点时间, 去发掘他的需求,并询问他的喜好或者想法,可能会好一点。(建议行动)
          • R: 这样他会觉得比较有参与感,并且觉得你已了解他们公司的需求了。
    • 反馈: 就是向别人讲出自己对其工作绩效或工作相关行为的意见。
    • 反馈的原则:及时, 具体, 真诚。

三. 绩效面谈

沟通和共识 贯穿于绩效管理的整个过程。

image.png

评估面谈的目的:

  1. 总结过去的工作业绩
  2. 关注员工的未来发展

评估面谈的注意事项:

image.png

评估面谈的最终目标

image.png

EBA(情感账户)

在平时的工作中, 尤其是跨部门合作,难免要遇到找人帮人的情况,这时候如果你和对面的人很熟, 往往就会推行的比较顺利。

保持一个好的关系,往往能起到意想不到的作用。 这种关系, 就是所谓的 EBA - Emotion Bank Account.

如果在情感账户中,余额都是正的, 就意味着下次你找别人帮忙, 就可能比较顺利。

所以, 要注意维护我们的账户。

### 1. 存款必须是连续的

前提:对方必须认定,这是一个存款的操作

绩效管理三循环

1. 绩效目标(设定与共识)

  • 设定目标
  • 胜任力/行为

2.绩效追踪

  • 追踪目标及能力
  • 绩效反馈及辅导

3. 绩效发展

  • 绩效评审
  • 持续发展

新官上任两把火

1. 工作盘点

工作盘点可以从横向纵向两个维度进行。

横向盘点, 比如:绩效,人员架构,培养, 文化, 等等
纵向盘点, 比如:绩效 => 关键流程 => 关键行为 => 评估能力 => 赋能

2. 人员盘点

人员盘点, 大体上, 可以分成三类:

  1. Top 员工
  2. 一般员工
  3. 待改进员工

我就先简单的根据 工作能力工作意愿来划分:

1. Top 员工

一般是指: 工作能力强, 工作意愿强的双高员工

2. 一般员工

一般是指:工作能力, 工作意愿, 一强一弱的员工。

3. 待改进员工

一般是指: 工作能力, 工作意愿 都需要提升的员工。

在进行人员盘点的时候, 尤其要关注两类人: Top 员工新员工.

如果到一个陌生的团队做leader, 一定要注意亲自培养出Top 员工, 这部分员工, 往往选自新员工。

新员工除了是后备军, 还可以用来制衡制约老员工, 一个长期没有新员工的团队是不健康的, 因为构不成一个完整的闭环, 造血机制是有缺陷的。

而且, 如何保持Top 员工 不滑落一般员工, 或者 跳槽跑路, 也是leader 应该考虑的问题。

向上管理

这个话题其实是两部分:

  1. 向上沟通
  2. 向上管理

向上管理非常重要, 也很容易被忽视。

当你的努力的方向和老板的战略一致, 利益一致, 往往能争取到更多的资源。

情绪 VS 事件

在和员工沟通的过程中, 如过员工有情绪, 就一定要先处理情绪。

在未解决情绪问题之前, 任何尝试解决事件的行为都是无效的。

举个例子:

某程序员最近经常加班, 回家很晚, 冷落了女朋友。

一天深夜, 回到家:

  • 女: 你为什么又回来这么晚?!
  • 男: 最近忙,赶项目。
  • 女: 你整天就知道说忙忙忙, 一年365天就没有不忙的, 你肯定是不爱我了!
  • 男: 我没有, 你别无理取闹好不好。。
  • 女: 我去理取闹!你说我无理取闹。。好, 我今天就跟你无理取闹了!
  • 男: 你够了!
  • 女: 你竟然吼我。。 你是不是外面有狗了。。。
  • 男: 我没有。。。
  • 女: 分手吧! 渣男!!
  • 男: 。。。
  • 女: 。。。

这个场景, 是我瞎编的, 如有雷同, 纯属偶然。

这个例子, 是用来解释一个概念: 事实 && 情绪带来的演绎

现实生活中, 给我们带来伤害的, 也往往是我们自己对一个事情的演绎:

回家晚 => 不爱我了 => 外面肯定是有狗了 => 渣男 => 必须分手

这位老实的程序员,喜提渣男称号, 一脸懵逼的被分手了。

在工作中也是一样,作为leader, 要学会区分 事实情绪带来的演绎, 学会还原事实

比如: "老板, 铁柱写的代码老是出bug, 今天开会还迟到,肯定是人品有问题。"

代码老是出bug, 今天开会还迟到, 这些是事实。
人品有问题, 是情绪的演绎。

铁柱莫名其妙被扣上了个人品有问题的帽子, 心里肯定会不爽。

在处理这类带有情绪的事件的时候, 要先处理情绪问题

可以采用两个办法:

  1. 改变身体姿态
  2. 转移战场

也可以通过连续的打断,至少2次以上,来延迟情绪的爆发。 当对方的表情或者身体姿态发生变化时, 说明我们的打断就是有效的。

这一招用在那个加班的情景:

  • 女: 你为什么又回来这么晚?!
  • 男: (拉着她的手走到窗台边,轻轻抱住她)亲爱的,对不起,最近真的太忙了,忽略了你的感受,这周末我们去看电影好不好, 最近有个绿帽侠特别好看。
  • 女: (心里一松: 他也是为了事业)嗯,好。
  • 男: 我们去外面走走吧
  • 女: 好呀好呀。
  • 男: :)
  • 女: ;)

你看看, 这不就好起来了吗。

上面这个场景, 也是我瞎编的,如有雷同, 纯属偶然

总结

以上内容, 是我根据课堂素材,笔记, 以及事后的回忆总结的, 不是课程的全部, 很多课堂互动的感受也未能完全表现出来, 只能把部分内容以文字的形式呈现给大家, 尽力了,希望能给朋友们一些帮助和启发。

后面会和另一个朋友对比一下内容, 可能会有所补充,感兴趣的话,可以关注这个帖。

最后献上培训的合照, 表示留念。

image.png

如果内容对你有帮助, 可以点赞, 留言, 表示支持。

查看原文

赞 37 收藏 23 评论 3

weiwei 回答了问题 · 2019-12-12

使用vscode编写react代码,声明一个组件时报错

提示错误的信息你得贴出来,不过大概率是楼上回答的那样,首字母没大写,把装饰器方法下面的那一行空行删了试试

关注 3 回答 2

weiwei 赞了文章 · 2019-12-10

利用 JS 实现多种图片相似度算法

image

在搜索领域,早已出现了“查找相似图片/相似商品”的相关功能,如 Google 搜图,百度搜图,淘宝的拍照搜商品等。要实现类似的计算图片相似度的功能,除了使用听起来高大上的“人工智能”以外,其实通过 js 和几种简单的算法,也能八九不离十地实现类似的效果。

在阅读本文之前,强烈建议先阅读完阮一峰于多年所撰写的《相似图片搜索的原理》相关文章,本文所涉及的算法也来源于其中。

体验地址:https://img-compare.netlify.com/

特征提取算法

为了便于理解,每种算法都会经过“特征提取”和“特征比对”两个步骤进行。接下来将着重对每种算法的“特征提取”步骤进行详细解读,而“特征比对”则单独进行阐述。

平均哈希算法

参考阮大的文章,“平均哈希算法”主要由以下几步组成:

第一步,缩小尺寸为8×8,以去除图片的细节,只保留结构、明暗等基本信息,摒弃不同尺寸、比例带来的图片差异。

第二步,简化色彩。将缩小后的图片转为灰度图像。

第三步,计算平均值。计算所有像素的灰度平均值。

第四步,比较像素的灰度。将64个像素的灰度,与平均值进行比较。大于或等于平均值,记为1;小于平均值,记为0。

第五步,计算哈希值。将上一步的比较结果,组合在一起,就构成了一个64位的整数,这就是这张图片的指纹。

第六步,计算哈希值的差异,得出相似度(汉明距离或者余弦值)。

明白了“平均哈希算法”的原理及步骤以后,就可以开始编码工作了。为了让代码可读性更高,本文的所有例子我都将使用 typescript 来实现。

图片压缩:

我们采用 canvas 的 drawImage() 方法实现图片压缩,后使用 getImageData() 方法获取 ImageData 对象。

export function compressImg (imgSrc: string, imgWidth: number = 8): Promise<ImageData> {
  return new Promise((resolve, reject) => {
    if (!imgSrc) {
      reject('imgSrc can not be empty!')
    }
    const canvas = document.createElement('canvas')
    const ctx = canvas.getContext('2d')
    const img = new Image()
    img.crossOrigin = 'Anonymous'
    img.onload = function () {
      canvas.width = imgWidth
      canvas.height = imgWidth
      ctx?.drawImage(img, 0, 0, imgWidth, imgWidth)
      const data = ctx?.getImageData(0, 0, imgWidth, imgWidth) as ImageData
      resolve(data)
    }
    img.src = imgSrc
  })
}

可能有读者会问,为什么使用 canvas 可以实现图片压缩呢?简单来说,为了把“大图片”绘制到“小画布”上,一些相邻且颜色相近的像素往往会被删减掉,从而有效减少了图片的信息量,因此能够实现压缩的效果:
image

在上面的 compressImg() 函数中,我们利用 new Image() 加载图片,然后设定一个预设的图片宽高值让图片压缩到指定的大小,最后获取到压缩后的图片的 ImageData 数据——这也意味着我们能获取到图片的每一个像素的信息。

关于 ImageData,可以参考 MDN 的文档介绍

图片灰度化

为了把彩色的图片转化成灰度图,我们首先要明白“灰度图”的概念。在维基百科里是这么描述灰度图像的:

在计算机领域中,灰度(Gray scale)数字图像是每个像素只有一个采样颜色的图像。

大部分情况下,任何的颜色都可以通过三种颜色通道(R, G, B)的亮度以及一个色彩空间(A)来组成,而一个像素只显示一种颜色,因此可以得到“像素 => RGBA”的对应关系。而“每个像素只有一个采样颜色”,则意味着组成这个像素的三原色通道亮度相等,因此只需要算出 RGB 的平均值即可:

// 根据 RGBA 数组生成 ImageData
export function createImgData (dataDetail: number[]) {
  const canvas = document.createElement('canvas')
  const ctx = canvas.getContext('2d')
  const imgWidth = Math.sqrt(dataDetail.length / 4)
  const newImageData = ctx?.createImageData(imgWidth, imgWidth) as ImageData
  for (let i = 0; i < dataDetail.length; i += 4) {
    let R = dataDetail[i]
    let G = dataDetail[i + 1]
    let B = dataDetail[i + 2]
    let Alpha = dataDetail[i + 3]

    newImageData.data[i] = R
    newImageData.data[i + 1] = G
    newImageData.data[i + 2] = B
    newImageData.data[i + 3] = Alpha
  }
  return newImageData
}

export function createGrayscale (imgData: ImageData) {
  const newData: number[] = Array(imgData.data.length)
  newData.fill(0)
  imgData.data.forEach((_data, index) => {
    if ((index + 1) % 4 === 0) {
      const R = imgData.data[index - 3]
      const G = imgData.data[index - 2]
      const B = imgData.data[index - 1]

      const gray = ~~((R + G + B) / 3)
      newData[index - 3] = gray
      newData[index - 2] = gray
      newData[index - 1] = gray
      newData[index] = 255 // Alpha 值固定为255
    }
  })
  return createImgData(newData)
}

ImageData.data 是一个 Uint8ClampedArray 数组,可以理解为“RGBA数组”,数组中的每个数字取值为0~255,每4个数字为一组,表示一个像素的 RGBA 值。由于ImageData 为只读对象,所以要另外写一个 creaetImageData() 方法,利用 context.createImageData() 来创建新的 ImageData 对象。

拿到灰度图像以后,就可以进行指纹提取的操作了。

指纹提取

在“平均哈希算法”中,若灰度图的某个像素的灰度值大于平均值,则视为1,否则为0。把这部分信息组合起来就是图片的指纹。由于我们已经拿到了灰度图的 ImageData 对象,要提取指纹也就变得很容易了:

export function getHashFingerprint (imgData: ImageData) {
  const grayList = imgData.data.reduce((pre: number[], cur, index) => {
    if ((index + 1) % 4 === 0) {
      pre.push(imgData.data[index - 1])
    }
    return pre
  }, [])
  const length = grayList.length
  const grayAverage = grayList.reduce((pre, next) => (pre + next), 0) / length
  return grayList.map(gray => (gray >= grayAverage ? 1 : 0)).join('')
}

image


通过上述一连串的步骤,我们便可以通过“平均哈希算法”获取到一张图片的指纹信息(示例是大小为8×8的灰度图):
image

感知哈希算法

关于“感知哈希算法”的详细介绍,可以参考这篇文章:《基于感知哈希算法的视觉目标跟踪》

image

简单来说,该算法经过离散余弦变换以后,把图像从像素域转化到了频率域,而携带了有效信息的低频成分会集中在 DCT 矩阵的左上角,因此我们可以利用这个特性提取图片的特征。

该算法的步骤如下:

  • 缩小尺寸:pHash以小图片开始,但图片大于88,3232是最好的。这样做的目的是简化了DCT的计算,而不是减小频率。
  • 简化色彩:将图片转化成灰度图像,进一步简化计算量。
  • 计算DCT:计算图片的DCT变换,得到32*32的DCT系数矩阵。
  • 缩小DCT:虽然DCT的结果是3232大小的矩阵,但我们只要保留左上角的88的矩阵,这部分呈现了图片中的最低频率。
  • 计算平均值:如同均值哈希一样,计算DCT的均值。
  • 计算hash值:这是最主要的一步,根据8*8的DCT矩阵,设置0或1的64位的hash值,大于等于DCT均值的设为”1”,小于DCT均值的设为“0”。组合在一起,就构成了一个64位的整数,这就是这张图片的指纹。

回到代码中,首先添加一个 DCT 方法:

function memoizeCosines (N: number, cosMap: any) {
  cosMap = cosMap || {}
  cosMap[N] = new Array(N * N)

  let PI_N = Math.PI / N

  for (let k = 0; k < N; k++) {
    for (let n = 0; n < N; n++) {
      cosMap[N][n + (k * N)] = Math.cos(PI_N * (n + 0.5) * k)
    }
  }
  return cosMap
}

function dct (signal: number[], scale: number = 2) {
  let L = signal.length
  let cosMap: any = null

  if (!cosMap || !cosMap[L]) {
    cosMap = memoizeCosines(L, cosMap)
  }

  let coefficients = signal.map(function () { return 0 })

  return coefficients.map(function (_, ix) {
    return scale * signal.reduce(function (prev, cur, index) {
      return prev + (cur * cosMap[L][index + (ix * L)])
    }, 0)
  })
}

然后添加两个矩阵处理方法,分别是把经过 DCT 方法生成的一维数组升维成二维数组(矩阵),以及从矩阵中获取其“左上角”内容。

// 一维数组升维
function createMatrix (arr: number[]) {
  const length = arr.length
  const matrixWidth = Math.sqrt(length)
  const matrix = []
  for (let i = 0; i < matrixWidth; i++) {
    const _temp = arr.slice(i * matrixWidth, i * matrixWidth + matrixWidth)
    matrix.push(_temp)
  }
  return matrix
}

// 从矩阵中获取其“左上角”大小为 range × range 的内容
function getMatrixRange (matrix: number[][], range: number = 1) {
  const rangeMatrix = []
  for (let i = 0; i < range; i++) {
    for (let j = 0; j < range; j++) {
      rangeMatrix.push(matrix[i][j])
    }
  }
  return rangeMatrix
}

复用之前在“平均哈希算法”中所写的灰度图转化函数createGrayscale(),我们可以获取“感知哈希算法”的特征值:

export function getPHashFingerprint (imgData: ImageData) {
  const dctData = dct(imgData.data as any)
  const dctMatrix = createMatrix(dctData)
  const rangeMatrix = getMatrixRange(dctMatrix, dctMatrix.length / 8)
  const rangeAve = rangeMatrix.reduce((pre, cur) => pre + cur, 0) / rangeMatrix.length
  return rangeMatrix.map(val => (val >= rangeAve ? 1 : 0)).join('')
}

image

颜色分布法

首先摘抄一段阮大关于“颜色分布法“的描述:
image

阮大把256种颜色取值简化成了4种。基于这个原理,我们在进行颜色分布法的算法设计时,可以把这个区间的划分设置为可修改的,唯一的要求就是区间的数量必须能够被256整除。算法如下:

// 划分颜色区间,默认区间数目为4个
// 把256种颜色取值简化为4种
export function simplifyColorData (imgData: ImageData, zoneAmount: number = 4) {
  const colorZoneDataList: number[] = []
  const zoneStep = 256 / zoneAmount
  const zoneBorder = [0] // 区间边界
  for (let i = 1; i <= zoneAmount; i++) {
    zoneBorder.push(zoneStep * i - 1)
  }
  imgData.data.forEach((data, index) => {
    if ((index + 1) % 4 !== 0) {
      for (let i = 0; i < zoneBorder.length; i++) {
        if (data > zoneBorder[i] && data <= zoneBorder[i + 1]) {
          data = i
        }
      }
    }
    colorZoneDataList.push(data)
  })
  return colorZoneDataList
}

image

把颜色取值进行简化以后,就可以把它们归类到不同的分组里面去:

export function seperateListToColorZone (simplifiedDataList: number[]) {
  const zonedList: string[] = []
  let tempZone: number[] = []
  simplifiedDataList.forEach((data, index) => {
    if ((index + 1) % 4 !== 0) {
      tempZone.push(data)
    } else {
      zonedList.push(JSON.stringify(tempZone))
      tempZone = []
    }
  })
  return zonedList
}

image

最后只需要统计每个相同的分组的总数即可:

export function getFingerprint (zonedList: string[], zoneAmount: number = 16) {
  const colorSeperateMap: {
    [key: string]: number
  } = {}
  for (let i = 0; i < zoneAmount; i++) {
    for (let j = 0; j < zoneAmount; j++) {
      for (let k = 0; k < zoneAmount; k++) {
        colorSeperateMap[JSON.stringify([i, j, k])] = 0
      }
    }
  }
  zonedList.forEach(zone => {
    colorSeperateMap[zone]++
  })
  return Object.values(colorSeperateMap)
}

image

内容特征法

”内容特征法“是指把图片转化为灰度图后再转化为”二值图“,然后根据像素的取值(黑或白)形成指纹后进行比对的方法。这种算法的核心是找到一个“阈值”去生成二值图。
image

对于生成灰度图,有别于在“平均哈希算法”中提到的取 RGB 均值的办法,在这里我们使用加权的方式去实现。为什么要这么做呢?这里涉及到颜色学的一些概念。

具体可以参考这篇《Grayscale to RGB Conversion》,下面简单梳理一下。

采用 RGB 均值的灰度图是最简单的一种办法,但是它忽略了红、绿、蓝三种颜色的波长以及对整体图像的影响。以下面图为示例,如果直接取得 RGB 的均值作为灰度,那么处理后的灰度图整体来说会偏暗,对后续生成二值图会产生较大的干扰。

image

那么怎么改善这种情况呢?答案就是为 RGB 三种颜色添加不同的权重。鉴于红光有着更长的波长,而绿光波长更短且对视觉的刺激相对更小,所以我们要有意地减小红光的权重而提升绿光的权重。经过统计,比较好的权重配比是 R:G:B = 0.299:0.587:0.114。

image

于是我们可以得到灰度处理函数:

enum GrayscaleWeight {
  R = .299,
  G = .587,
  B = .114
}

function toGray (imgData: ImageData) {
  const grayData = []
  const data = imgData.data

  for (let i = 0; i < data.length; i += 4) {
    const gray = ~~(data[i] * GrayscaleWeight.R + data[i + 1] * GrayscaleWeight.G + data[i + 2] * GrayscaleWeight.B)
    data[i] = data[i + 1] = data[i + 2] = gray
    grayData.push(gray)
  }

  return grayData
}

上述函数返回一个 grayData 数组,里面每个元素代表一个像素的灰度值(因为 RBG 取值相同,所以只需要一个值即可)。接下来则使用“大津法”(Otsu's method)去计算二值图的阈值。关于“大津法”,阮大的文章已经说得很详细,在这里就不展开了。我在这个地方找到了“大津法”的 Java 实现,后来稍作修改,把它改为了 js 版本:

/ OTSU algorithm
// rewrite from http://www.labbookpages.co.uk/software/imgProc/otsuThreshold.html
export function OTSUAlgorithm (imgData: ImageData) {
  const grayData = toGray(imgData)
  let ptr = 0
  let histData = Array(256).fill(0)
  let total = grayData.length

  while (ptr < total) {
    let h = 0xFF & grayData[ptr++]
    histData[h]++
  }

  let sum = 0
  for (let i = 0; i < 256; i++) {
    sum += i * histData[i]
  }

  let wB = 0
  let wF = 0
  let sumB = 0
  let varMax = 0
  let threshold = 0

  for (let t = 0; t < 256; t++) {
    wB += histData[t]
    if (wB === 0) continue
    wF = total - wB
    if (wF === 0) break

    sumB += t * histData[t]

    let mB = sumB / wB
    let mF = (sum - sumB) / wF

    let varBetween = wB * wF * (mB - mF) ** 2

    if (varBetween > varMax) {
      varMax = varBetween
      threshold = t
    }
  }

  return threshold
}

OTSUAlgorithm() 函数接收一个 ImageData 对象,经过上一步的 toGray() 方法获取到灰度值列表以后,根据“大津法”算出最佳阈值然后返回。接下来使用这个阈值对原图进行处理,即可获取二值图。

export function binaryzation (imgData: ImageData, threshold: number) {
  const canvas = document.createElement('canvas')
  const ctx = canvas.getContext('2d')
  const imgWidth = Math.sqrt(imgData.data.length / 4)
  const newImageData = ctx?.createImageData(imgWidth, imgWidth) as ImageData
  for (let i = 0; i < imgData.data.length; i += 4) {
    let R = imgData.data[i]
    let G = imgData.data[i + 1]
    let B = imgData.data[i + 2]
    let Alpha = imgData.data[i + 3]
    let sum = (R + G + B) / 3

    newImageData.data[i] = sum > threshold ? 255 : 0
    newImageData.data[i + 1] = sum > threshold ? 255 : 0
    newImageData.data[i + 2] = sum > threshold ? 255 : 0
    newImageData.data[i + 3] = Alpha
  }
  return newImageData
}

image

若图片大小为 N×N,根据二值图“非黑即白”的特性,我们便可以得到一个 N×N 的 0-1 矩阵,也就是指纹:

image

特征比对算法

经过不同的方式取得不同类型的图片指纹(特征)以后,应该怎么去比对呢?这里将介绍三种比对算法,然后分析这几种算法都适用于哪些情况。

汉明距离

摘一段维基百科关于“汉明距离”的描述:

在信息论中,两个等长字符串之间的汉明距离(英语:Hamming distance)是两个字符串对应位置的不同字符的个数。换句话说,它就是将一个字符串变换成另外一个字符串所需要替换的字符个数。

例如:

  • 1011101与1001001之间的汉明距离是2。
  • 2143896与2233796之间的汉明距离是3。
  • "toned"与"roses"之间的汉明距离是3。

明白了含义以后,我们可以写出计算汉明距离的方法:

export function hammingDistance (str1: string, str2: string) {
  let distance = 0
  const str1Arr = str1.split('')
  const str2Arr = str2.split('')
  str1Arr.forEach((letter, index) => {
    if (letter !== str2Arr[index]) {
      distance++
    }
  })
  return distance
}

使用这个 hammingDistance() 方法,来验证下维基百科上的例子:
image

验证结果符合预期。

知道了汉明距离,也就可以知道两个等长字符串之间的相似度了(汉明距离越小,相似度越大):

相似度 = (字符串长度 - 汉明距离) / 字符串长度

余弦相似度

从维基百科中我们可以了解到关于余弦相似度的定义:

余弦相似性通过测量两个向量的夹角的余弦值来度量它们之间的相似性。0度角的余弦值是1,而其他任何角度的余弦值都不大于1;并且其最小值是-1。从而两个向量之间的角度的余弦值确定两个向量是否大致指向相同的方向。两个向量有相同的指向时,余弦相似度的值为1;两个向量夹角为90°时,余弦相似度的值为0;两个向量指向完全相反的方向时,余弦相似度的值为-1。这结果是与向量的长度无关的,仅仅与向量的指向方向相关。余弦相似度通常用于正空间,因此给出的值为0到1之间。

注意这上下界对任何维度的向量空间中都适用,而且余弦相似性最常用于高维正空间。

image

余弦相似度可以计算出两个向量之间的夹角,从而很直观地表示两个向量在方向上是否相似,这对于计算两个 N×N 的 0-1 矩阵的相似度来说非常有用。根据余弦相似度的公式,我们可以把它的 js 实现写出来:

export function cosineSimilarity (sampleFingerprint: number[], targetFingerprint: number[]) {
  // cosθ = ∑n, i=1(Ai × Bi) / (√∑n, i=1(Ai)^2) × (√∑n, i=1(Bi)^2) = A · B / |A| × |B|
  const length = sampleFingerprint.length
  let innerProduct = 0
  for (let i = 0; i < length; i++) {
    innerProduct += sampleFingerprint[i] * targetFingerprint[i]
  }
  let vecA = 0
  let vecB = 0
  for (let i = 0; i < length; i++) {
    vecA += sampleFingerprint[i] ** 2
    vecB += targetFingerprint[i] ** 2
  }
  const outerProduct = Math.sqrt(vecA) * Math.sqrt(vecB)
  return innerProduct / outerProduct
}

两种比对算法的适用场景

明白了“汉明距离”和“余弦相似度”这两种特征比对算法以后,我们就要去看看它们分别适用于哪些特征提取算法的场景。

首先来看“颜色分布法”。在“颜色分布法”里面,我们把一张图的颜色进行区间划分,通过统计不同颜色区间的数量来获取特征,那么这里的特征值就和“数量”有关,也就是非 0-1 矩阵。

image

显然,要比较两个“颜色分布法”特征的相似度,“汉明距离”是不适用的,只能通过“余弦相似度”来进行计算。

接下来看“平均哈希算法”和“内容特征法”。从结果来说,这两种特征提取算法都能获得一个 N×N 的 0-1 矩阵,且矩阵内元素的值和“数量”无关,只有 0-1 之分。所以它们同时适用于通过“汉明距离”和“余弦相似度”来计算相似度。

image

计算精度

明白了如何提取图片的特征以及如何进行比对以后,最重要的就是要了解它们对于相似度的计算精度。

本文所讲的相似度仅仅是通过客观的算法来实现,而判断两张图片“像不像”却是一个很主观的问题。于是我写了一个简单的服务,可以自行把两张图按照不同的算法和精度去计算相似度:

https://img-compare.netlify.com/

经过对不同素材的多方比对,我得出了下列几个非常主观的结论。

  • 对于两张颜色较为丰富,细节较多的图片来说,“颜色分布法”的计算结果是最符合直觉的。
    image
  • 对于两张内容相近但颜色差异较大的图片来说,“内容特征法”和“平均/感知哈希算法”都能得到符合直觉的结果。
    image
  • 针对“颜色分布法“,区间的划分数量对计算结果影响较大,选择合适的区间很重要。
    image

总结一下,三种特征提取算法和两种特征比对算法各有优劣,在实际应用中应该针对不同的情况灵活选用。

总结

本文是在拜读阮一峰的两篇《相似图片搜索的原理》之后,经过自己的实践总结以后而成。由于对色彩、数学等领域的了解只停留在浅显的层面,文章难免有谬误之处,如果有发现表述得不正确的地方,欢迎留言指出,我会及时予以更正。

查看原文

赞 100 收藏 64 评论 5

认证与成就

  • 获得 70 次点赞
  • 获得 9 枚徽章 获得 1 枚金徽章, 获得 1 枚银徽章, 获得 7 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

注册于 2016-11-21
个人主页被 1.2k 人浏览