万年打野易大师

万年打野易大师 查看完整档案

北京编辑北京工商大学  |  信息工程 编辑  |  填写所在公司/组织填写个人主网站
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 个人简介什么都没有

个人动态

万年打野易大师 发布了文章 · 3月4日

鼠标移入放大图片预览效果实现

商城项目中,有鼠标移入图片放大的功能,研究一下实现

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta http-equiv="X-UA-Compatible" content="IE=edge" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Image zoom</title>
    </head>
    <style>
        body {
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
        }

        #image {
            width: 300px;
            height: 300px;
            background-color: #000;
            background-image: url(https://placekitten.com/900/900);
            background-size: 300px 300px;
            background-repeat: no-repeat;
        }

        #image[zoomed] {
            background-size: 900px 900px;
            background-position: calc(var(--x) * 100%) calc(var(--y) * 100%);
        }
    </style>
    <body>
        <div id="image"></div>
    </body>
    <script>
        let el = document.querySelector('#image')

        // PC端操作
        el.addEventListener('mouseenter', enterHandler)
        el.addEventListener('mousemove', moveHandler)
        el.addEventListener('mouseleave', leaveHandler)

        // 移动端操作
        el.addEventListener('touchstart', enterHandler)
        el.addEventListener('touchmove', moveHandler)
        el.addEventListener('touchend', leaveHandler)

        function enterHandler(e) {
            e.target.setAttribute('zoomed', 1)
            moveHandler(e)
        }

        function moveHandler(e) {
            // getBoundingClientRect用于获取元素相对于视窗的位置集合。集合中有top, right, bottom, left等属性
            let rect = e.target.getBoundingClientRect()
            let offsetX, offsetY
            let isH5 = ['touchstart', 'touchmove', 'touchend'].includes(e.type)
            // 是移动端,并且touches事件存在
            if (isH5 && e.touches[0]) {
                offsetX = e.touches[0].pageX - rect.left
                offsetY = e.touches[0].pageY - rect.top

                e.preventDefault()
            } else {
                // PC端
                offsetX = e.offsetX
                offsetY = e.offsetY
            }
            // 元素的位置信息
            let x = offsetX / rect.width
            let y = offsetY / rect.height
            // 设置元素属性,用于计算background-position的位置
            e.target.style.setProperty('--x', x)
            e.target.style.setProperty('--y', y)
        }

        function leaveHandler(e) {
            e.target.removeAttribute('zoomed')
            moveHandler(e)
        }
    </script>
</html>

具体效果复制下去打开看看

查看原文

赞 2 收藏 1 评论 0

万年打野易大师 发布了文章 · 2月25日

JS高级运算符

在代码精简优化过程中,我们总会想着要去简练我们的代码,尽量做到用最少的代码完成最好的功能

下面介绍4个JS开发优化的高级运算符使用

1、(param ? res1 : res2)三元运算符

三元运算符,又叫条件运算符

接受三个运算数:条件 ? 条件为真时要执行的表达式 : 条件为假时要执行的表达式

基本示例:

function isChecked(checked) {
    return checked ? '是' : '否' 
}
console.log(isChecked(true)) // => 是
console.log(isChecked(false)) // => 否

三元运算符用于变量赋值

let time = 0
let have = (time > 23) ? '睡觉' : '工作' 
console.log(have) // => '工作'

三元运算符用于空赋值的行为

let x = 1
let x = (x !== null || x !== undefined) ? x : 2
console.log(x) // => 1

用在函数中

function getValue(x, y) {
    return (x == null || x == undefined) ? y : x 
}
getValue(null, 8) // => 8
getValue(4, 8) // => 4

2、?? 非空运算符

如果第一个参数不是 null/undefined(译者注:这里只有两个假值,但是 JS 中假值包含:未定义 undefined、空对象 null、数值 0、空数字 NaN、布尔 false,空字符串''),将返回第一个参数,否则返回第二个参数。

基本示例:

null ?? 5 // => 5
3 ?? 5 // => 3

开发业务场景优化:
某些时候,数值为0或者为空字符串"",不应该舍弃0和空字符串""

// 优化前
let prev = 1
let current = 0
let noAccount = null
let future = false
function test(param) {
    return param || `不存在`
}
console.log(test(prev)) // => 1
console.log(test(current)) // => 不存在
console.log(test(noAccount)) // => 不存在
console.log(test(future)) // => 不存在

// 优化后
let prev = 1
let current = 0
let noAccount = null
let future = false
function test(param) {
    return param ?? `不存在`
}
console.log(test(prev)) // => 1
console.log(test(current)) // => 0
console.log(test(noAccount)) // => 不存在
console.log(test(future)) // => false

概括地说 ?? 运算符允许我们在忽略错误值(如 0 和空字符串、false)的同时指定默认值。

3、??= 空赋值运算符

与非空运算符相关

let x = null
let y = 5
console.log(x ??= y) // => 5
console.log(x = (x ?? y)) // => 5

仅当值为 null 或 undefined 时,此赋值运算符才会赋值。
上面的例子强调了这个运算符本质上是空赋值的语法糖(译者注,类似的语法糖:a = a + b 可写成 a += b )。
接下来,让我们看看这个运算符与默认参数(译者注,默认参数是 ES6 引入的新语法,仅当函数参数为 undefined 时,给它设置一个默认值)的区别:

function settingsWithNull(options) {
    options.speed ??= 1
    options.diff ??= 'easy' 
    return options
}
function settingsWithDefaultParams(speed=1, diff='easy') {
    return {speed, diff}
}
settingsWithNullish({speed: null, diff: null}) // => {speed: 1, diff: 'easy'}
settingsWithDefaultParams(undefined, null) // => {speed: null, diff: null}

4、?. 链判断运算符

链判断运算符?. 允许开发人员读取深度嵌套在对象链中的属性值,而不必验证每个引用。当引用为空时,表达式停止计算并返回 undefined。

let book = {
    name: 'js高教4',
    content: {
        first: 'hello world',
        second: 'good work'
    }
}
console.log(book.price?.zh) // => undefined
查看原文

赞 0 收藏 0 评论 0

万年打野易大师 发布了文章 · 2月22日

js立即执行函数

对于非匿名的立即执行函数需要注意以下一点

let foo = 1
(function foo() {
    foo = 10
    console.log(foo)
}()) //-> ƒ foo() { foo = 10 ; console.log(foo) }

因为当 JS 解释器在遇到非匿名的立即执行函数时,会创建一个辅助的特定对象,然后将函数名称作为这个对象的属性,因此函数内部才可以访问到 foo,但是这又个值是只读的,所以对它的赋值并不生效,所以打印的结果还是这个函数,并且外部的值也没有发生更改。

查看原文

赞 0 收藏 0 评论 0

万年打野易大师 发布了文章 · 1月30日

nuxt构建项目

一、引入并创建nuxt项目

确认已经安装 npx ( npx 依附于 npm5.2.0 安装引入)

npx create-nuxt-app <my-project>

或者在 npm v6.1版本后 可以这样创建:

npm init nuxt-app@latest <my-project>

或者用: yarn:

yarn create nuxt-app <my-project>

二、引入axios库

1、使用nuxt自带模块

npm i @nuxtjs/axios -s

1) 、在 nuxt.config.js 中引入

export default {
  /*
   ** Runtime Config
   */
  publicRuntimeConfig: {
    axios: {
      baseURL: 'https://api.nuxtjs.dev'
    }
  },
  /*
   ** Modules - https://nuxtjs.org/docs/2.x/directory-structure/modules
   */
  modules: ['@nuxtjs/axios']
}

2) 、页面内使用 $axios 获取数据,并用 $config 获取 API 接口的 URL

async asyncData({$axios}) {
  let { res } = await $axios.get(`https://xxx.com/api/xxx`) 
  console.log(res)    
}

3) 、设置公共拦截

~/plugins 创建 axios.js 文件

export default function ({store, redirect, req, router, $axios })  {
    // baseUrl可以在上面的配置中,也可在这儿配置
    $axios.defaults.baseURL = 'http://XXX/api';
    if(process.server){
        // 获取服务端的token,对应函数请自行封装
        var token = getcookiesInServer(req).token;
    }
    if(process.client){
        // 获取客户端token,对应函数请自行封装
        var token = getcookiesInClient('token');
    }
    // request拦截器
    $axios.onRequest(config => {
        if(process.client){
            // 客户端下,请求进度条开始
            NProgress.start();
        }
        // 将获取到token加入到请求头中
        config.headers.common['Authorization'] = token;
    });
    // response拦截器,数据返回后,可以先在这里进行一个简单的判断
    $axios.interceptors.response.use(
        response => {
            if(process.client){
                // 客户端下, 请求进度条结束
                NProgress.done();
            }
            // return response
            if(response.data.code == 401){
                // 返回401,token验证失败
                removeToken("token");
                  // 重定向到登录页面, 这里做一个判断,容错:req.url 未定义
                if(req.url){
                    redirect("/sign?ref="+req.url)
                }else{
                    redirect("/sign")
                }
            }else if(response.data.code == 404){
                // 重定向到404页面
                redirect("/")
            }
            else{
                // 请求接口数据正常,返回数据
                return response
            }
        },
        error => {
            if(process.client){
                NProgress.done();
            }
            if(error.response.status == 500){
                // http状态500,服务器内部错误,重定向到500页面
                redirect("/500.htm")
            }
            if(error.response.status == 404){
                // http状态500,请求API找不到,重定向到404页面
                redirect("/404.html")
            }
            return Promise.reject(error.response)   // 返回接口返回的错误信息
        })
}

2、外部引入axios

npm i axios -s

1) 、创建request文件夹

在文件夹内创建 http.js文件, urls文件夹, apis文件夹

http.js

import axios from 'axios'
import Vue from 'vue'

const ajax = axios.create({
    baseURL: process.env.baseUrl,
    timeout: 30 * 1000
})

// 请求拦截器
ajax.interceptors.request.use(
    config => {
        // const Token = getToken()
        // if (Token) {
        //     config.headers['token'] = getToken()
        // }
        config.headers['Content-Type'] = 'application/json;chartset=utf-8'
        // config.headers["Authorization"] = "Bearer atwerjjhqkwehtjhsdfqwehjhwrgqre";
        return config
    },
    error => {
        throw new Error(`请求错误: ${error}`)
    }
)
// 响应拦截器
ajax.interceptors.response.use(
    response => {
        if (response.status === 200) {
            // 处理返回流文件报错
            if (response.config.responseType === 'blob') {
                var reader = new FileReader()
                reader.readAsText(response.data)
                reader.onload = e => {
                    const result = JSON.parse(e.target.result)
                    if (result.code !== 200) {
                        Vue.prototype.$message.error(result.msg)
                    }
                }
            }
            if (response.data.code === 200) {
                return response.data.data
            } else {
                Vue.prototype.$message.error(response.data.message)
                return Promise.reject(response.data)
            }
        } else {
            return response
        }
    },
    error => {
        throw new Error(error)
        // throw new Error(`请求错误: ${error}`)
    }
)

/*
 * @params {config} 参数从API传递过来
 * @params @{config} {url} 请求地址
 * @params @{config} {data} 请求数据
 */
export function get(config) {
    let obj = {
        url: config.url,
        method: 'get',
        params: {
            ...config.data,
        }
    }
    return ajax(obj)
}

export function post(config) {
    let obj = {
        url: config.url,
        method: 'post',
        data: {
            ...config.data,
        }
    }
    return ajax(obj)
}

export function upload(config) {
    let obj = {
        url: config.url,
        method: 'post',
        data: config.data,
        headers: {
            'Content-Type': 'multipart/form-data'
        }
    }
    return ajax(obj)
}

urls文件夹下创建对应模块的url信息

例如 urls/user.js

export default {
    userinfo: '/user/userinfo',
    userList: '/user/userList'
}

apis文件夹下创建对应模块的api函数

例如 apis/user.js

import { get, post } from '../http.js'
import user from '../urls/user'

export function getUserinfo(params) {
    return get({ url: userUrls.userinfo, params })
}

export function getUserList(params) {
    return post({ url: userUrls.userList, params })
}

页面内正常使用我们熟知的方式引入api函数去调接口获取数据

3、axios跨域配置

1) 、使用官方axios模块时的配置

export default {
    axios: {
        proxy: true,
        prefix: '/api', // baseURL
        credentials: true,
        retry: { retries: 3 }
    },
    proxy: {
        '/api': {
            target: 'http://192.168.xxx.xxx:xxxx', // 代理地址
            changeOrigin: true,
            pathRewrite: {
                '^/api': '', //将 /api 替换掉
            }
        },
    }
}

2) 、使用官方axios模块,引入官方模块 @nuxtjs/proxy 配置

npm i @nuxtjs/proxy -D

nuxt.config.js 中配置

modules: [
    '@nuxtjs/axios',
    '@nuxtjs/proxy'
],
proxy: [
    [
        '/api', 
        { 
            target: 'http://localhost:3001', // api主机
            pathRewrite: { '^/api' : '/' }
        }
    ]
]

3) 、使用外部引入的方式

配合后端部署设置nginx

三、登录状态持久化

npm i -S cookie-universal-nuxt

nuxt.config.js 中配置

modules: [
    // 登录状态持久化
    ['cookie-universal-nuxt', { parseJSON: true }]
],

store配置请参考本文下面的i18n部分, store/index.js

四、引入ant-design-vue组件库

npm i -S ant-design-vue
npm install babel-plugin-import --save-dev

1、按需引入配置

// nuxt.config.js
{
    build: {
        babel: {
            plugins: [
                [
                    'import',
                    {
                        libraryName: 'ant-design-vue',
                        libraryDirectory: 'es',
                        style: true
                        // 默认不使用该选项,即不导入样式 , 注意 ant-design-vue 使用 js 文件引入样式
                        // true 表示 import 'ant-design-vue/es/component/style'
                        // 'css' 表示 import 'ant-design-vue/es/component/style/css'
                    }
                ]
            ]
        }
    }
}

~/plugins 文件夹创建 antd-ui.js 文件

import Vue from 'vue'

Vue.config.productionTip = false

import { Button } from 'ant-dsign-vue'

Vue.use(Button)

2、antd-icon 过大

不是所有的图标我们都能用到

~/plugins 文件夹创建 antd-icons.js 文件

export {
    // 需要使用到的 Icons
    InfoCircleFill,
    DownOutline,
    UpOutline,
    RightOutline,
    LeftOutline
} from '@ant-design/icons'
// nuxt.config.js
const CompressionPlugin = require('compression-webpack-plugin')
const path = require('path')

export default {
    build: {
        vendor: ['axios', 'ant-design-vue'],
        // 解决less加载使用不了的问题
        loaders: {
            less: {
                javascriptEnabled: true
            }
        },
        analyze: {
            analyzerMode: 'static'
        },
        // 使用Babel与特定的依赖关系进行转换
        transpile: [/ant-design-vue/],
        extend(config, ctx) {
            // 配置eslint
            if (ctx.isClient) {
                config.module.rules.push({
                    enforce: 'pre',
                    test: /\.(js|vue)$/,
                    loader: 'eslint-loader',
                    exclude: /(node_modules)/
                })
                // ant-design-vue 的icon组件,按需引入需要的图标,文件太大
                config.resolve.alias['@ant-design/icons/lib/dist$'] = path.resolve(__dirname, './plugins/antd-icons.js') // 引入需要的
            }
        },
        // 打包构建优化
        plugins: [
            new CompressionPlugin({
                test: /\.js$|\.html$|\.css/, // 匹配文件名
                threshold: 10240, // 对超过10kb的数据进行压缩
                deleteOriginalAssets: false // 是否删除原文件
            })
        ],
        optimization: {
            splitChunks: {
                minSize: 10000,
                maxSize: 250000
            }
        }
    }
}

注意: 这儿安装 compression-webpack-plugin 插件会报错,指定插件版本就行了

npm i --save-dev compression-webpack-plugin@6.1.1

提醒:图标使用<a-icon type="close"></a-icon>来显示

如果有组件内使用的,例如

<a-rate v-model="val">
    <a-icon #character type="star"></a-icon>
</a-rate>

五、国际化

1、引入i18n

npm  i vue-i18n --save

2、在 plugins 下创建 i18n.js

// nuxt.config.js
export default {
    plugins: [
        '@/plugins/antd-ui',
        { src: '@/plugins/lazy-load', ssr: false },
        '@/plugins/i18n.js',
        { src: '~/plugins/lodash.js', ssr: false },
        { src: '~/plugins/moment.js', ssr: false },
        { src: '@/plugins/vue-swiper.js', mode: 'client' },
    ],
}
import Vue from 'vue'
import VueI18n from 'vue-i18n'
Vue.use(VueI18n)

export default ({ app, store }) => {
    // Set i18n instance on app
    // This way we can use it in middleware and pages asyncData/fetch
    app.i18n = new VueI18n({
        locale: store.state.locale,
        fallbackLocale: store.state.locale,
        messages: {
            'en-US': require('@/language/en-US.json'),
            'zh-CN': require('@/language/zh-CN.json')
        },
        silentTranslationWarn: true
    })

    app.i18n.path = link => {
        // 如果是默认语言,就省略
        if (app.i18n.locale === app.i18n.fallbackLocale) {
            return `/${link}`
        }
        return `/${app.i18n.locale}/${link}`
    }
}

3、在 middleware 下创建 i18n.js

// nuxt.config.js
export default {
    router: {
        middleware: ['i18n']
    },
}
export default function({ isHMR, app, store, route, params, error, redirect }) {
    const defaultLocale = app.i18n.fallbackLocale
    // If middleware is called from hot module replacement, ignore it
    if (isHMR) return
    // Get locale from params
    const locale = params.lang || defaultLocale

    if (store.state.locales.indexOf(locale) === -1) {
        return error({ message: '页面未找到.', statusCode: 404 })
    }
    // Set locale
    // store.commit('SET_LANG', locale)
    app.i18n.locale = store.state.locale
    // If route is /<defaultLocale>/... -> redirect to /...
    if (locale === defaultLocale && route.fullPath.indexOf('/' + defaultLocale) === 0) {
        const toReplace = '^/' + defaultLocale + (route.fullPath.indexOf('/' + defaultLocale + '/') === 0 ? '/' : '')
        const re = new RegExp(toReplace)
        return redirect(route.fullPath.replace(re, '/'))
    }
}

4、在 store 下创建 index.js

import { getToken } from '@/lib/token.js'
const state = () => ({
    token: '',
    locales: ['en-US', 'zh-CN'],
    locale: 'en-US'
})

const mutations = {
    setToken(state, token) {
        state.token = token
    },
    SET_LANG(state, locale) {
        if (state.locales.indexOf(locale) !== -1) {
            state.locale = locale
        }
    }
}

const actions = {
    async nuxtServerInit({ commit }, { app }) {
        let token = getToken(app) || ''
        commit('setToken', token)
        //还可以获取一些通用信息,比如个人信息,通用配置什么的
    }
}
export default {
    state,
    actions,
    mutations
}

5、在 language 下创建 en-US.jsonzh-CN.json

更多语言库,请按照规则添加

配置对应的变量属性名称,在页面用

<span>{{ $t('变量名') }}</span>

对应antd-vue组件库国际化,官方推荐使用 config-provider 组件

// default.vue页面
<template>
    <a-config-provider :locale="lang">
        <div class="layout-container">
            <a-layout>
                <a-layout-header>header</a-layout-header>
                <a-layout-content><Nuxt /></a-layout-content>
                <a-layout-footer>footer</a-layout-footer>
                <a-back-top />
            </a-layout>
        </div>
    </a-config-provider>
</template>
<script>
import { mapState } from 'vuex'
import language from '../language/antd-lang/index'
import moment from 'moment'
import 'moment/locale/zh-cn'
moment.locale('en')

export default {
    data() {
        return {}
    },
    computed: {
        ...mapState({
            locale: state => state.locale
        }),
        lang: function() {
            let l = null
            switch (this.locale) {
                case 'zh-CN':
                    l = language.zhCN
                    moment.locale('zh-cn')
                    break
                case 'en-US':
                    l = language.enUS
                    moment.locale('en')
                    break
            }
            return l
        }
    }
}
</script>

language 下创建 antd-lang/index.js, 包含所有的支持语言库

// 阿拉伯
import arEG from '~/node_modules/ant-design-vue/es/locale/ar_EG'
// 保加利亚语
import bgBG from '~/node_modules/ant-design-vue/es/locale/bg_BG'
// 加泰罗尼亚语
import caES from '~/node_modules/ant-design-vue/es/locale/ca_ES'
// 捷克语
import csCZ from '~/node_modules/ant-design-vue/es/locale/cs_CZ'
// 德语
import deDE from '~/node_modules/ant-design-vue/es/locale/de_DE'
// 希腊语
import elGR from '~/node_modules/ant-design-vue/es/locale/el_GR'
// 英语
import enGB from '~/node_modules/ant-design-vue/es/locale/en_GB'
// 英语(美式)
import enUS from '~/node_modules/ant-design-vue/es/locale/en_US'
// 西班牙语
import esES from '~/node_modules/ant-design-vue/es/locale/es_ES'
// 爱沙尼亚语
import etEE from '~/node_modules/ant-design-vue/es/locale/et_EE'
// 波斯语
import faIR from '~/node_modules/ant-design-vue/es/locale/fa_IR'
// 芬兰语
import fiFI from '~/node_modules/ant-design-vue/es/locale/fi_FI'
// 法语(比利时)
import frBE from '~/node_modules/ant-design-vue/es/locale/fr_BE'
// 法语
import frFR from '~/node_modules/ant-design-vue/es/locale/fr_FR'
// 冰岛语
import isIS from '~/node_modules/ant-design-vue/es/locale/is_IS'
// 意大利语
import itIT from '~/node_modules/ant-design-vue/es/locale/it_IT'
// 日语
import jaJP from '~/node_modules/ant-design-vue/es/locale/ja_JP'
// 韩语/朝鲜语
import koKR from '~/node_modules/ant-design-vue/es/locale/ko_KR'
// 挪威
import nbNO from '~/node_modules/ant-design-vue/es/locale/nb_NO'
// 荷兰语(比利时)
import nlBE from '~/node_modules/ant-design-vue/es/locale/nl_BE'
// 荷兰语
import nlNL from '~/node_modules/ant-design-vue/es/locale/nl_NL'
// 波兰语
import plPL from '~/node_modules/ant-design-vue/es/locale/pl_PL'
// 葡萄牙语(巴西)
import ptBR from '~/node_modules/ant-design-vue/es/locale/pt_BR'
// 葡萄牙语
import ptPT from '~/node_modules/ant-design-vue/es/locale/pt_PT'
// 斯洛伐克语
import skSK from '~/node_modules/ant-design-vue/es/locale/sk_SK'
// 塞尔维亚
import srRS from '~/node_modules/ant-design-vue/es/locale/sr_RS'
// 斯洛文尼亚
import slSI from '~/node_modules/ant-design-vue/es/locale/sl_SI'
// 瑞典语
import svSE from '~/node_modules/ant-design-vue/es/locale/sv_SE'
// 泰语
import thTH from '~/node_modules/ant-design-vue/es/locale/th_TH'
// 土耳其语
import trTR from '~/node_modules/ant-design-vue/es/locale/tr_TR'
// 俄罗斯语
import ruRU from '~/node_modules/ant-design-vue/es/locale/ru_RU'
// 乌克兰语
import ukUA from '~/node_modules/ant-design-vue/es/locale/uk_UA'
// 越南语
import viVN from '~/node_modules/ant-design-vue/es/locale/vi_VN'
// 简体中文
import zhCN from '~/node_modules/ant-design-vue/es/locale/zh_CN'
// 繁体中文
import zhTW from '~/node_modules/ant-design-vue/es/locale/zh_TW'

export default {
    arEG,
    bgBG,
    caES,
    csCZ,
    deDE,
    elGR,
    enGB,
    enUS,
    esES,
    etEE,
    faIR,
    fiFI,
    frBE,
    frFR,
    isIS,
    itIT,
    jaJP,
    koKR,
    nbNO,
    nlBE,
    nlNL,
    plPL,
    ptBR,
    ptPT,
    skSK,
    srRS,
    slSI,
    svSE,
    thTH,
    trTR,
    ruRU,
    ukUA,
    viVN,
    zhCN,
    zhTW
}

六、scss、less样式变量全局使用

SASS: `yarn add sass-loader node-sass`
LESS: `yarn add less-loader less`
Stylus: `yarn add stylus-loader stylus`
yarn add @nuxtjs/style-resources
npm i @nuxtjs/style-resources --save-dev

配置请参考:@nuxtjs/style-resources

// nuxt.config.js
export default {
    // 下面这两个配置用一个就行了
    modules: ['@nuxtjs/style-resources'],
    buildModules: ['@nuxtjs/style-resources'],
    // 配置我们需要用到的less、scss全局变量,然后在.vue文件内直接引入
    styleResources: {
        // your settings here
        sass: [],
        scss: ['./assets/vars/*.scss', './assets/abstracts/_mixins.scss'],
        less: ['./assets/vars/*.less'],
        stylus: []
    },
}
查看原文

赞 5 收藏 3 评论 0

万年打野易大师 发布了文章 · 1月30日

nuxt使用vue-awesome-swiper组件采坑记录

商城项目使用nuxt架构

使用轮播图,选择了时下最热门的vue-awesome-swiper作为基础组件。但是使用过程中遇到的坑,真的很难受

nuxt的版本是2.14.6

在vuecli中构建使用vue-awesome-swiper组件没有任何问题,
但是加上ssr就一直会报错,原生组件的切换上一个下一个的按钮不显示,即便是显示了点击也不会生效,后面排查也不知道是不是电脑硬件的问题,我们使用要求切换按钮需要自定义(包括按钮行为)

这儿直接上我们目前的组件代码

<template>
    <div v-swiper:mySwiper="sweiperConfig.options" :style="{ width: `${sweiperConfig.width}px`, height: `${sweiperConfig.height}px` }">
        <div class="swiper-wrapper">
            <slot name="swiperbody"></slot>
        </div>
        <a-icon
            v-if="iconConfig.prevShow"
            class="swiper-left-btn"
            :style="{ ...iconConfig.iconCss }"
            type="left"
            :disabled="iconConfig.prevDisabled"
            @click="prev"
        />
        <a-icon
            v-if="iconConfig.nextShow"
            class="swiper-right-btn"
            :style="{ ...iconConfig.iconCss }"
            type="right"
            :disabled="iconConfig.nextDisabled"
            @click="next"
        />
    </div>
</template>

<script>
export default {
    props: {
        // sweiper配置,options是sweiper官方配置,具体请查看官方文档
        sweiperConfig: {
            type: Object,
            default: () => {
                return {
                    options: {
                        type: Object,
                        default: () => {}
                    },
                    width: {
                        type: Number,
                        default: 300
                    },
                    height: {
                        type: Number,
                        default: 300
                    }
                }
            }
        },
        // icon配置,这是配置自定义的图标的样式
        iconConfig: {
            type: Object,
            default: () => {
                return {
                    prevShow: {
                        type: Boolean,
                        default: true
                    },
                    nextShow: {
                        type: Boolean,
                        default: true
                    },
                    prevDisabled: {
                        type: Boolean,
                        default: false
                    },
                    nextDisabled: {
                        type: Boolean,
                        default: false
                    },
                    iconCss: {
                        type: Object,
                        default: () => {
                            return {
                                'font-size': '30px',
                                padding: '10px',
                                top: '50%'
                            }
                        }
                    }
                }
            }
        }
    },
    mounted() {
        // 回传mySwiper实例,方便父组件使用该实例进行操作(调用方法或者事件)
        // 在父组件中对应的$emit方法定义到method中就能直接获取该实例,具体使用参考下文代码块中
        this.$emit('getSwiperObj', this.mySwiper)
    },
    methods: {
        prev() {
            this.mySwiper.slidePrev() //内置方法,往上翻一页
            // 当前的页码
            this.$emit('prevClick', this.mySwiper.realIndex + 1)
        },
        next() {
            this.mySwiper.slideNext() //内置方法,往下翻一页
            // 当前的页码
            this.$emit('nextClick', this.mySwiper.realIndex + 1)
        }
    }
}
</script>

<style lang="scss" scoped>
@mixin swiper-btn {
    background-color: #fff;
    border-radius: 50%;
    position: absolute;
    z-index: 2;
}
.swiper-wrapper {
    .swiper-slide {
        &:hover {
            cursor: pointer;
        }
    }
    .img-wrapper img {
        margin: auto;
        width: 200px;
        height: 100px;
        background-image: linear-gradient(gray 100%, transparent 0);
    }
}
.swiper-left-btn {
    @include swiper-btn;
    left: 5px;
}
.swiper-right-btn {
    @include swiper-btn;
    right: 5px;
}
.swiper-left-btn,
.swiper-right-btn {
    &:hover {
        background-color: #ccc;
    }
}
</style>

需要特别注意的是,<slot name="swiperbody"></slot>,这里的slot,在页面内使用要用v-slot的方式,这儿v-slot简写,不清楚的去vue官网查看

class="sweiper-slide"这个属性不能丢掉,这是sweiper源码获取子元素用的

<VueSwiper
    :sweiper-config="sweiperConfig"
    :icon-config="iconConfig"
    @getSwiperObj="getSwiperObj"
    @prevClick="prevClick"
    @nextClick="nextClick"
>
    <template #swiperbody>
       <img v-for="(img, index) in imgs" :key="index" :data-original="img.url" class="swiper-slide" />
    </template>
</VueSwiper>

参考文章: nuxt使用vue-awesome-sweiper采坑

查看原文

赞 1 收藏 1 评论 0

万年打野易大师 回答了问题 · 1月1日

解决请教一个webpack的问题

用环境变量注入啊

关注 3 回答 2

万年打野易大师 发布了文章 · 2020-12-30

JS中间件封装api调用处理过程,解耦一堆复杂操作

秉持低耦合的观念,拆分各个功能函数,做到清晰控制,数据单向流转

定义中间件对象

class Middleware {
    constructor({ core, params }) {
        // 传入的封装axios
        this.core = core;
        // 传入的axios参数
        this.params = params;
        // 存放链式调用的中间件函数
        this.cache = [];
    }
    use(fn) {
        if (typeof fn !== "function") {
            throw "middleware must be a function";
        }
        this.cache.push(fn);
        return this;
    }
    next(data) {
        if (this.middlewares && this.middlewares.length > 0) {
            var ware = this.middlewares.shift();
            ware.call(this, this.next.bind(this), data);
        }
        return data;
    }
    async go() {
        // 请求获取数据,以备后序程序使用
        let result = await this.core(this.params);
        //复制函数,待执行
        this.middlewares = this.cache.map(function(fn) {
            return fn;
        });
        // 向后传递参数,中间插件模型处理流程数据
        this.next(result);
    }
}

使用

// 导入参数
var onion = new Middleware({core: request, params});
onion.use(function (next, data) {
    console.log(1);
    console.log(data);
    // 向后传递数据
    next(data);
    console.log("1结束");
});
onion.use(function (next, data) {
    console.log(2);
    console.log(data);
    // 向后传递数据
    next(data);
    console.log("2结束");
});
onion.use(function (next, data) {
    console.log(3);
    console.log(data);
    console.log("3结束");
});
// 上一步没有调用next,后面的函数都不执行
onion.use(function (next, data) {
    console.log(4);
    next(data);
    console.log("4结束");
});
onion.go();
// 1
// {a: 1, b: 2}
// 2
// {a: 1, b: 2, c: 3}
// 3
// {a: 1, b: 2, c: 3, d: 4}
// 3结束
// 2结束
// 1结束

配合API接口数据返回后的操作函数

function handleStatus(next, res) {
    console.log(res);
    next(res);
}
function handleAuth(next, res) {
    console.log(res);
    next(res);
}
function handlePosition(next, res) {
    console.log(res);
    next(res);
}
function handleForbiddenList(next, res) {
    console.log(res);
    next(res);
}
function handleLoad(next, res) {
    console.log(res);
    next(res);
}

通过中间件以此注册

// 导入参数
var onion = new Middleware({core: request, params});
onion.use(handleStatus);
onion.use(handleAuth);
onion.use(handlePosition);
onion.use(handleForbiddenList);
onion.use(handleLoad);
onion.go();
// 函数里面可以用异步函数,获取到这个流程处理完的值
// let res = await onion.go();
查看原文

赞 13 收藏 13 评论 0

万年打野易大师 回答了问题 · 2020-12-22

解决使用js数组去重操作!

看了下面这么多人的回答,我总结一个:
1、数据层面的操作在后端返回的时候,不应该已经处理完了?还要前端做去重,这是后端做的事
2、你这是级联的数据,还是多级多选的模式,看起来数据量不大,但是影响的是后续的操作
3、数组去重的方式很简单,你去重后,级联选择器(也就是多选框)已经被重置了,你想在筛选后去做初始化类似的赋值你得重新生成一次联选择器

有问题欢迎交流

关注 5 回答 4

万年打野易大师 回答了问题 · 2020-12-22

请问一下,移动端页面加载问题

懒加载啊,有专门的的插件,比自己这倒腾好多了,用约束的模式去做

关注 6 回答 4

万年打野易大师 发布了文章 · 2020-12-10

Vue.extend有怎么用?

Vue.extend传入一个符合vue选项的对象,返回一个vue构造类,

Vue.extend在任何地方都能拿到vue实例。

例如,做一个提醒类的弹窗,只是更改了文字内容和颜色、图标

import alert from "./components/alert";

Vue.mixin({
    methods: {
        showNotice: function(msg, el) {
            const Constructor = Vue.extend(alert);
            const vm = new Constructor();
            vm.$data.message = msg;
            vm.$mount(el);
        }
    }
})
<template>
    <button @click="showNotice('弹我', $refs.cover)">点我</button>
    <div ref="cover">这是一个弹出层的蒙版</div>
</template>

这样在页面中只要调用那个定义的方法,然后传参(参数可以用对象的形式传过去,自己改一下就行了)过去就能实现弹窗,不用再每个页面去引入组件,data定义一个变量去保存message,components注册组件,再调用加载绑定

实际开发过程中很多地方都能用这样方式去抽离组件,UI库里面的一些组件也是这样的模式装载的。

Vue.extend另一个用得很多的是在单元测试中

image.png

查看原文

赞 1 收藏 1 评论 0

万年打野易大师 发布了文章 · 2020-12-10

vuex按需加载,避免首页初始化所有数据

大型项目中,不管首页需不需要那么多变量,vuex都会一次性打包进去,vuex会很大,因为都是一次性加载的

需求:按需异步加载vuex
解决方案如下

第一步: 把.vue文件对应的vuex拆分出来,

image.png

第二步:在xxx.vue文件里面添加 nameisNeedVuex 属性

image.png

注意:为了保证引入store下的文件名字跟组件命名一样,请自行选择合理的命名方式

第三步:用到 Vue.useVue.mixin 两个api

在main.js中添加如下代码:

Vue.use(function() {
    Vue.mixin({
        beforeCreate: function() {
            // $options是组件选项,包含组件.vue文件的 `export default` 的属性
            // 为了拿到在组件定义的是否按需加载的属性值 `isNeedVuex`
            if(this.$options.isNeedVuex) {
                // 需要设置.vue文件的name属性,跟单文件组件名字命名一样
                let name = this.$options.name;
                import("./store/modules/" + name).then((res) => {
                    console.log(res);
                    // res.default就是代表我们在store/modules文件夹下对应文件的export default对象
                    // registerModule是vuex自带的方法,请自行搜索
                    // 第一个参数是动态注入的模块名,第二个参数是模块导出对象
                    this.$store.registerModule(name, res.default);
                });
            }
        }
    });
});

修改评论中提到的问题,可作为参考

在组件销毁后注销对应的store,在router/index.js中添加如下代码:

router.beforeEach((to, from, next) => {
    if (from.name) {
        // unregisterModule跟前面的registerModule对应,是vuex的api,请自行搜索
        store.unregisterModule(from.name);
    }
    // <!--to do others-->
    next();
});
查看原文

赞 25 收藏 17 评论 9

万年打野易大师 发布了文章 · 2020-12-09

vue-server-renderer实现vue项目改造服务端渲染

这是一篇教程,从创建项目到改造项目

vue-cli创建一个项目

在放你做demo的地方,创建一个项目

vue create vue-ssr
// 如果你安装了vue-cli4,选择vue2的版本,以下的改进过程是按vue2来做的

经过漫长的等待,下载好文件开始我们的改造之路

文件目录

进入vue-ssr文件夹,使用命令

vue ui

把vue-router装上
image.png

先安装几个依赖插件

// 安不上用cnpm,yarn,npx
npm i vue-server-renderer  -D
npm i express -D
npm i webpack-node-externals -D
npm i lodash.merge -D
npm i cross-env -D

修改package.json文件

"scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
}
改成
"scripts": {
    "build:client": "vue-cli-service build", 
    "build:server": "cross-env WEBPACK_TARGET=node vue-cli-service build --mode server", 
    "build": "npm run build:server && npm run build:client" 
}

根目录下创建vue.config.js

// 服务器渲染的两个插件,控制server和client
const VueSSRServerPlugin = require("vue-server-renderer/server-plugin"); // 生成服务端包
const VueSSRClientPlugin = require("vue-server-renderer/client-plugin"); // 生成客户端包

const nodeExternals = require("webpack-node-externals");
const merge = require("lodash.merge");

// 环境变量:决定入口是客户端还是服务端,WEBPACK_TARGET在启动项中设置的,见package.json文件
const TARGET_NODE = process.env.WEBPACK_TARGET === "node";
const target = TARGET_NODE ? "server" : "client";

module.exports = {
  css: {
    extract: false
  },
  outputDir: "./dist/" + target,
  configureWebpack: () => ({
    // 将 entry 指向应用程序的 server / client 文件
    entry: `./src/entry-${target}.js`,
    // 对 bundle renderer 提供 source map 支持
    devtool: "source-map",
    // 这允许 webpack 以 Node 适用方式处理动态导入(dynamic import),
    // 并且还会在编译 Vue 组件时告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
    target: TARGET_NODE ? "node" : "web",
    node: TARGET_NODE ? undefined : false,
    output: {
      // 此处配置服务器端使用node的风格构建
      libraryTarget: TARGET_NODE ? "commonjs2" : undefined
    },
    // 外置化应用程序依赖模块。可以使服务器构建速度更快,并生成较小的 bundle 文件。
    externals: TARGET_NODE
      ? nodeExternals({
          // 不要外置化 webpack 需要处理的依赖模块。
          // 可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件,
          // 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单(以前叫whitelist,为了避免美国的人种歧视,改成了allowlist)
          allowlist: [/\.css$/]
        })
      : undefined,
    optimization: {
      splitChunks: TARGET_NODE ? false : undefined
    },
    // 这是将服务器的整个输出构建为单个 JSON 文件的插件。
    // 服务端默认文件名为 `vue-ssr-server-bundle.json`
    // 客户端默认文件名为 `vue-ssr-client-manifest.json`
    plugins: [TARGET_NODE ? new VueSSRServerPlugin() : new VueSSRClientPlugin()]
  }),
  chainWebpack: config => {
    config.module
      .rule("vue")
      .use("vue-loader")
      .tap(options => {
        merge(options, {
          optimizeSSR: false
        });
      });
  }
};

修改路由文件

import Vue from 'vue';
import Router from 'vue-router';
import Home from '../views/Home.vue';
import About from '../views/About.vue';

Vue.use(Router);

// 这里为什么不导出一个router实例?
// 每次用户请求都需要创建新router实例,如果用户请求多次都用一个实例会造成数据污染
export function createRouter() {
    return new Router({
        // 一定要history模式,因为,hash模式更改路径不会刷新,具体原因自行查询
        mode: 'history',
        routes: [
            {path: '/', name: 'Home',component: Home},
            {path: '/about', name: 'About', component: About},
        ]
    })
}

修改main.js文件

import Vue from "vue";
import App from "./App.vue";
import { createRouter } from "./router";
import store from "./store";

Vue.config.productionTip = false;

const router = createRouter();

// 这里的挂载($mount("#app"))放到entry-client.js文件里面,后面会说到
export function createApp() {
    const app = new Vue({
        router,
        store,
        render: (h) => h(App),
    });
    return { app, router };
}

在src下添加entry-client.js和entry-server.js文件

entry-client.js

import {createApp} from './main.js';

const {app, router} = createApp();

router.onReady(()=>{
    app.$mount("#app");
})

entry-server.js

import {createApp} from "./main.js";
// context实际上就是server/index.js里面传参,后面会说到server/index.js
export default context => {
    return new Promise((resolve, reject) => {
        const {app, router} = createApp();
        router.push(context.url)
        router.onReady(()=>{
            // 是否匹配到我们要用的组件
            const matchs = router.getMatchedComponents();
            if(!matchs) {
                return reject({code: 404})
            }
            resolve(app);
        }, reject);
    })
}

在src下创建server/index.js

// nodejs服务器
const express = require("express");
const fs = require("fs");

// 创建express实例和vue实例
const app = express();

// 创建渲染器
const { createBundleRenderer } = require("vue-server-renderer");
const serverBundle = require("../../dist/server/vue-ssr-server-bundle.json");
const clientManifest = require("../../dist/client/vue-ssr-client-manifest.json");
// 这儿引入的文件是不同于index.html的问题,具体文件下面会讲到
const template = fs.readFileSync("../../public/index.ssr.html", "utf-8"); // 宿主模板文件
const renderer = createBundleRenderer(serverBundle, {
    runInNewContext: false,
    template,
    clientManifest,
});

// 中间件处理静态文件请求
app.use(express.static("../../dist/client", { index: false })); // 为false是不让它渲染成dist/client/index.html
// app.use(express.static('../dist/client'))

// 前端请求什么我都不关心,所有的路由处理交给vue
app.get("*", async (req, res) => {
    try {
        const context = {
            url: req.url,
            title: "ssr test",
        };
        // nodejs流数据,文件太大,用renderToString会卡
        const stream = renderer.renderToStream(context);
        let buffer = [];
        stream.on("data", (chunk) => {
            buffer.push(chunk);
        });
        stream.on("end", () => {
            res.end(Buffer.concat(buffer));
        });
    } catch (error) {
        console.log(error);
        res.status(500).send("服务器内部错误");
    }
});

app.listen(3000, () => {
    console.log("渲染服务器启动成功");
});

在public下面创建index.ssr.html文件

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <!--vue-ssr-outlet-->
    <!--上面这个一定要留着,它是服务端渲染模版的标记,没有就会报错,不信可以删了试一下-->
</body>
</html>

然后所有的改造完成,运行命令

// 先构建两个json文件
npm run build

再到server文件夹下运行

node index.js
// 如果显示: `渲染服务器启动成功`, 在浏览器打开 `localhost:3000` 端口,就能看到我们的页面

整完这,你再去玩儿nuxt,你感觉好多了,因为nuxt不用配路由,自己生成,连路由传参都设定好了

查看原文

赞 11 收藏 11 评论 2

万年打野易大师 关注了专栏 · 2020-12-08

CodeGuide | 程序员编码指南

公众号:bugstack虫洞栈,回复:设计模式,可以下载《重学Java设计模式》PDF,全网下载量17万+ | 这是一本互联网真实案例实践书籍。以落地解决方案为核心,从实际业务中抽离出,交易、营销、秒杀、中间件、源码等22个真实场景,来学习设计模式的运用。

关注 15510

万年打野易大师 关注了用户 · 2020-12-08

阿宝哥 @angular4

http://www.semlinker.com/
聚焦全栈,专注分享 Angular、TypeScript、Node.js/Java 、Spring 技术栈等全栈干货

欢迎各位小伙伴关注本人公众号全栈修仙之路

关注 2383

万年打野易大师 发布了文章 · 2020-12-08

视频处理,让video活灵活现

Web 开发者们一直以来想在 Web 中使用音频和视频,但早些时候,传统的 Web 技术不能够在 Web 中嵌入音频和视频,所以一些像 Flash、Silverlight 的专利技术在处理这些内容上变得很受欢迎。

这些技术能够正常的工作,但是却有着一系列的问题,包括无法很好的支持 HTML/CSS 特性、安全问题,以及可行性问题。

幸运的是,当 HTML5 标准公布后,其中包含许多的新特性,包括 <video><audio> 标签,以及一些 JavaScript APIs 用于对其进行控制。随着通信技术和网络技术的不断发展,目前音视频已经成为大家生活中不可或缺的一部分。此外,伴随着 5G 技术的慢慢普及,实时音视频领域还会有更大的想象空间。

接下来本文将从八个方面入手,全方位带你一起探索前端 Video 播放器和主流的流媒体技术。阅读完本文后,你将了解以下内容:

  • 为什么一些网页中的 Video 元素,其视频源地址是采用 Blob URL 的形式;
  • 什么是 HTTP Range 请求及流媒体技术相关概念;
  • 了解 HLS、DASH 的概念、自适应比特率流技术及流媒体加密技术;
  • 了解 FLV 文件结构、flv.js 的功能特性与使用限制及内部的工作原理;
  • 了解 MSE(Media Source Extensions)API 及相关的使用;
  • 了解视频播放器的原理、多媒体封装格式及 MP4 与 Fragmented MP4 封装格式的区别;

在最后将介绍如何实现播放器截图、如何基于截图生成 GIF、如何使用 Canvas 播放视频及如何实现色度键控等功能。

一、传统的播放模式

大多数 Web 开发者对 <video> 都不会陌生,在以下 HTML 片段中,我们声明了一个 <video> 元素并设置相关的属性,然后通过 <source> 标签设置视频源和视频格式:

<video id="mse" autoplay=true playsinline controls="controls">
   <source data-original="https://h5player.bytedance.com/video/mp4/xgplayer-demo-720p.mp4" type="video/mp4">
   你的浏览器不支持Video标签
</video>

上述代码在浏览器渲染之后,在页面中会显示一个 Video 视频播放器,具体如下图所示:

(图片来源:https://h5player.bytedance.co...

通过 Chrome 开发者工具,我们可以知道当播放 「xgplayer-demo-720p.mp4」 视频文件时,发了 3 个 HTTP 请求:

此外,从图中可以清楚地看到,头两个 HTTP 请求响应的状态码是 「206」。这里我们来分析第一个 HTTP 请求的请求头和响应头:

在上面的请求头中,有一个 range: bytes=0- 首部信息,该信息用于检测服务端是否支持 Range 请求。如果在响应中存在 Accept-Ranges 首部(并且它的值不为 “none”),那么表示该服务器支持范围请求。

在上面的响应头中, Accept-Ranges: bytes 表示界定范围的单位是 bytes 。这里Content-Length 也是有效信息,因为它提供了要下载的视频的完整大小。

1.1 从服务器端请求特定的范围

假如服务器支持范围请求的话,你可以使用 Range 首部来生成该类请求。该首部指示服务器应该返回文件的哪一或哪几部分。

1.1.1 单一范围

我们可以请求资源的某一部分。这里我们使用 Visual Studio Code 中的 REST Client 扩展来进行测试,在这个例子中,我们使用 Range 首部来请求 www.example.com  首页的前 1024 个字节。

对于使用 REST Client 发起的 「单一范围请求」,服务器端会返回状态码为 「206 Partial Content」 的响应。而响应头中的 「Content-Length」 首部现在用来表示先前请求范围的大小(而不是整个文件的大小)。「Content-Range」 响应首部则表示这一部分内容在整个资源中所处的位置。

1.1.2 多重范围

Range 头部也支持一次请求文档的多个部分。请求范围用一个逗号分隔开。比如:

$ curl http://www.example.com -i -H "Range: bytes=0-50, 100-150"

对于该请求会返回以下响应信息:

因为我们是请求文档的多个部分,所以每个部分都会拥有独立的 「Content-Type」「Content-Range」 信息,并且使用 boundary 参数对响应体进行划分。

1.1.3 条件式范围请求

当重新开始请求更多资源片段的时候,必须确保自从上一个片段被接收之后该资源没有进行过修改。

「If-Range」 请求首部可以用来生成条件式范围请求:假如条件满足的话,条件请求就会生效,服务器会返回状态码为 206 Partial 的响应,以及相应的消息主体。假如条件未能得到满足,那么就会返回状态码为 「200 OK」 的响应,同时返回整个资源。该首部可以与「Last-Modified」 验证器或者 「ETag」 一起使用,但是二者不能同时使用。

1.1.4 范围请求的响应

与范围请求相关的有三种状态:

  • 在请求成功的情况下,服务器会返回 「206 Partial Content」 状态码。
  • 在请求的范围越界的情况下(范围值超过了资源的大小),服务器会返回 「416 Requested Range Not Satisfiable」 (请求的范围无法满足) 状态码。
  • 在不支持范围请求的情况下,服务器会返回 「200 OK」 状态码。

剩余的两个请求,阿宝哥就不再详细分析了。感兴趣的小伙伴,可以使用 Chrome 开发者工具查看一下具体的请求报文。

通过第 3 个请求,我们可以知道整个视频的大小大约为 7.9 MB。若播放的视频文件太大或出现网络不稳定,则会导致播放时,需要等待较长的时间,这严重降低了用户体验。

那么如何解决这个问题呢?要解决该问题我们可以使用流媒体技术,接下来我们来介绍流媒体。

二、流媒体

流媒体是指将一连串的媒体数据压缩后,经过网上分段发送数据,在网上即时传输影音以供观赏的一种技术与过程,此技术使得数据包得以像流水一样发送;如果不使用此技术,就必须在使用前下载整个媒体文件。

流媒体实际指的是一种新的媒体传送方式,有声音流、视频流、文本流、图像流、动画流等,而非一种新的媒体。流媒体最主要的技术特征就是流式传输,它使得数据可以像流水一样传输。流式传输是指通过网络传送媒体技术的总称。实现流式传输主要有两种方式:顺序流式传输(Progressive Streaming)和实时流式传输(Real Time Streaming)。

目前网络上常见的流媒体协议:

通过上表可知,不同的协议有着不同的优缺点。在实际使用过程中,我们通常会在平台兼容的条件下选用最优的流媒体传输协议。比如,在浏览器里做直播,选用 HTTP-FLV 协议是不错的,性能优于 RTMP+Flash,延迟可以做到和 RTMP+Flash 一样甚至更好。

而由于 HLS 延迟较大,一般只适合视频点播的场景,但由于它在移动端拥有较好的兼容性,所以在接受高延迟的条件下,也是可以应用在直播场景。

讲到这里相信有些小伙伴会好奇,对于 Video 元素来说使用流媒体技术之后与传统的播放模式有什么直观的区别。下面阿宝哥以常见的 HLS 流媒体协议为例,来简单对比一下它们之间的区别。

通过观察上图,我们可以很明显地看到,当使用 HLS 流媒体网络传输协议时,<video>元素 src 属性使用的是 blob:// 协议。讲到该协议,我们就不得不聊一下 Blob 与 Blob URL。

2.1 Blob

Blob(Binary Large Object)表示二进制类型的大对象。在数据库管理系统中,将二进制数据存储为一个单一个体的集合。Blob 通常是影像、声音或多媒体文件。「在 JavaScript 中 Blob 类型的对象表示不可变的类似文件对象的原始数据。」

Blob 由一个可选的字符串 type(通常是 MIME 类型)和 blobParts 组成:

MIME(Multipurpose Internet Mail Extensions)多用途互联网邮件扩展类型,是设定某种扩展名的文件用一种应用程序来打开的方式类型,当该扩展名文件被访问的时候,浏览器会自动使用指定应用程序来打开。多用于指定一些客户端自定义的文件名,以及一些媒体文件打开方式。

常见的 MIME 类型有:超文本标记语言文本 .html text/html、PNG图像 .png image/png、普通文本 .txt text/plain 等。

为了更直观的感受 Blob 对象,我们先来使用 Blob 构造函数,创建一个 myBlob 对象,具体如下图所示:

如你所见,myBlob 对象含有两个属性:size 和 type。其中 size 属性用于表示数据的大小(以字节为单位),type 是 MIME 类型的字符串。Blob 表示的不一定是 JavaScript 原生格式的数据。比如 File 接口基于 Blob,继承了 blob 的功能并将其扩展使其支持用户系统上的文件。

2.2 Blob URL/Object URL

Blob URL/Object URL 是一种伪协议,允许 Blob 和 File 对象用作图像,下载二进制数据链接等的 URL 源。在浏览器中,我们使用 URL.createObjectURL 方法来创建 Blob URL,该方法接收一个 Blob 对象,并为其创建一个唯一的 URL,其形式为 blob:<origin>/<uuid>,对应的示例如下:

blob:https://example.org/40a5fb5a-d56d-4a33-b4e2-0acf6a8e5f641

浏览器内部为每个通过 URL.createObjectURL 生成的 URL 存储了一个 URL → Blob 映射。因此,此类 URL 较短,但可以访问 Blob。生成的 URL 仅在当前文档打开的状态下才有效。但如果你访问的 Blob URL 不再存在,则会从浏览器中收到 404 错误。

上述的 Blob URL 看似很不错,但实际上它也有副作用。虽然存储了 URL → Blob 的映射,但 Blob 本身仍驻留在内存中,浏览器无法释放它。映射在文档卸载时自动清除,因此 Blob 对象随后被释放。但是,如果应用程序寿命很长,那不会很快发生。因此,如果我们创建一个 Blob URL,即使不再需要该 Blob,它也会存在内存中。

针对这个问题,我们可以调用 URL.revokeObjectURL(url) 方法,从内部映射中删除引用,从而允许删除 Blob(如果没有其他引用),并释放内存。

2.3 Blob vs ArrayBuffer

其实在前端除了 「Blob 对象」 之外,你还可能会遇到 「ArrayBuffer 对象」。它用于表示通用的,固定长度的原始二进制数据缓冲区。你不能直接操纵 ArrayBuffer 的内容,而是需要创建一个 TypedArray 对象或 DataView 对象,该对象以特定格式表示缓冲区,并使用该对象读取和写入缓冲区的内容。

Blob 对象与 ArrayBuffer 对象拥有各自的特点,它们之间的区别如下:

  • 除非你需要使用 ArrayBuffer 提供的写入/编辑的能力,否则 Blob 格式可能是最好的。
  • Blob 对象是不可变的,而 ArrayBuffer 是可以通过 TypedArrays 或 DataView 来操作。
  • ArrayBuffer 是存在内存中的,可以直接操作。而 Blob 可以位于磁盘、高速缓存内存和其他不可用的位置。
  • 虽然 Blob 可以直接作为参数传递给其他函数,比如 window.URL.createObjectURL()。但是,你可能仍需要 FileReader 之类的 File API 才能与 Blob 一起使用。
  • Blob 与 ArrayBuffer 对象之间是可以相互转化的:

    • 使用 FileReader 的 readAsArrayBuffer() 方法,可以把 Blob 对象转换为 ArrayBuffer 对象;
    • 使用 Blob 构造函数,如 new Blob([new Uint8Array(data]);,可以把 ArrayBuffer 对象转换为 Blob 对象。

在前端 AJAX 场景下,除了常见的 JSON 格式之外,我们也可能会用到 Blob 或 ArrayBuffer 对象:

function GET(url, callback) {
  let xhr = new XMLHttpRequest();
  xhr.open('GET', url, true);
  xhr.responseType = 'arraybuffer'; // or xhr.responseType = "blob";
  xhr.send();
  xhr.onload = function(e) {
    if (xhr.status != 200) {
      alert("Unexpected status code " + xhr.status + " for " + url);
      return false;
    }
    callback(new Uint8Array(xhr.response)); // or new Blob([xhr.response]);
  };
}

在以上示例中,通过为 xhr.responseType 设置不同的数据类型,我们就可以根据实际需要获取对应类型的数据了。介绍完上述内容,下面我们先来介绍目前应用比较广泛的 HLS 流媒体传输协议。

三、HLS

3.1 HLS 简介

HTTP Live Streaming(缩写是 HLS)是由苹果公司提出基于 HTTP 的流媒体网络传输协议,它是苹果公司 QuickTime X 和 iPhone 软件系统的一部分。它的工作原理是把整个流分成一个个小的基于 HTTP 的文件来下载,每次只下载一些。当媒体流正在播放时,客户端可以选择从许多不同的备用源中以不同的速率下载同样的资源,允许流媒体会话适应不同的数据速率。

此外,当用户的信号强度发生抖动时,视频流会动态调整以提供出色的再现效果。

(图片来源:https://www.wowza.com/blog/hl...

最初, 仅 iOS 支持 HLS。但现在 HLS 已成为专有格式,几乎所有设备都支持它。顾名思义,HLS(HTTP Live Streaming)协议通过标准的 HTTP Web 服务器传送视频内容。这意味着你无需集成任何特殊的基础架构即可分发 HLS 内容。

HLS 拥有以下特性:

  • HLS 将播放使用 H.264 或 HEVC / H.265 编解码器编码的视频。
  • HLS 将播放使用 AAC 或 MP3 编解码器编码的音频。
  • HLS 视频流一般被切成 10 秒的片段。
  • HLS 的传输/封装格式是 MPEG-2 TS。
  • HLS 支持 DRM(数字版权管理)。
  • HLS 支持各种广告标准,例如 VAST 和 VPAID。

为什么苹果要提出 HLS 这个协议,其实它的主要是为了解决 RTMP 协议存在的一些问题。比如 RTMP 协议不使用标准的 HTTP 接口传输数据,所以在一些特殊的网络环境下可能被防火墙屏蔽掉。但是 HLS 由于使用的 HTTP 协议传输数据,通常情况下不会遇到被防火墙屏蔽的情况。除此之外,它也很容易通过 CDN(内容分发网络)来传输媒体流。

3.2 HLS 自适应比特流

HLS 是一种自适应比特率流协议。因此,HLS 流可以动态地使视频分辨率自适应每个人的网络状况。如果你正在使用高速 WiFi,则可以在手机上流式传输高清视频。但是,如果你在有限数据连接的公共汽车或地铁上,则可以以较低的分辨率观看相同的视频。

在开始一个流媒体会话时,客户端会下载一个包含元数据的 Extended M3U(m3u8)Playlist 文件,用于寻找可用的媒体流。

(图片来源:https://www.wowza.com/blog/hl...

为了便于大家的理解,我们使用 hls.js 这个 JavaScript 实现的 HLS 客户端,所提供的 在线示例,来看一下具体的 m3u8 文件。

「x36xhzz.m3u8」

#EXTM3U
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2149280,CODECS="mp4a.40.2,avc1.64001f",RESOLUTION=1280x720,NAME="720"
url_0/193039199_mp4_h264_aac_hd_7.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=246440,CODECS="mp4a.40.5,avc1.42000d",RESOLUTION=320x184,NAME="240"
url_2/193039199_mp4_h264_aac_ld_7.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=460560,CODECS="mp4a.40.5,avc1.420016",RESOLUTION=512x288,NAME="380"
url_4/193039199_mp4_h264_aac_7.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=836280,CODECS="mp4a.40.2,avc1.64001f",RESOLUTION=848x480,NAME="480"
url_6/193039199_mp4_h264_aac_hq_7.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=6221600,CODECS="mp4a.40.2,avc1.640028",RESOLUTION=1920x1080,NAME="1080"
url_8/193039199_mp4_h264_aac_fhd_7.m3u8

通过观察 Master Playlist 对应的 m3u8 文件,我们可以知道该视频支持以下 5 种不同清晰度的视频:

  • 1920x1080(1080P)
  • 1280x720(720P)
  • 848x480(480P)
  • 512x288
  • 320x184

而不同清晰度视频对应的媒体播放列表,会定义在各自的 m3u8 文件中。这里我们以 720P 的视频为例,来查看其对应的 m3u8 文件:

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-TARGETDURATION:11
#EXTINF:10.000,
url_462/193039199_mp4_h264_aac_hd_7.ts
#EXTINF:10.000,
url_463/193039199_mp4_h264_aac_hd_7.ts
#EXTINF:10.000,
url_464/193039199_mp4_h264_aac_hd_7.ts
#EXTINF:10.000,
...
url_525/193039199_mp4_h264_aac_hd_7.ts
#EXT-X-ENDLIST

当用户选定某种清晰度的视频之后,将会下载该清晰度对应的媒体播放列表(m3u8 文件),该列表中就会列出每个片段的信息。HLS 的传输/封装格式是 MPEG-2 TS(MPEG-2 Transport Stream),是一种传输和存储包含视频、音频与通信协议各种数据的标准格式,用于数字电视广播系统,如 DVB、ATSC、IPTV 等等。

「需要注意的是利用一些现成的工具,我们是可以把多个 TS 文件合并为 mp4 格式的视频文件。」 如果要做视频版权保护,那我们可以考虑使用对称加密算法,比如 AES-128 对切片进行对称加密。当客户端进行播放时,先根据 m3u8 文件中配置的密钥服务器地址,获取对称加密的密钥,然后再下载分片,当分片下载完成后再使用匹配的对称加密算法进行解密播放。

对上述过程感兴趣的小伙伴可以参考 Github 上 video-hls-encrypt 这个项目,该项目深入浅出介绍了基于 HLS 流媒体协议视频加密的解决方案并提供了完整的示例代码。

(图片来源:https://github.com/hauk0101/v...

介绍完苹果公司推出的 HLS (HTTP Live Streaming)技术,接下来我们来介绍另一种基于 HTTP 的动态自适应流 —— DASH。

四、DASH

4.1 DASH 简介

「基于 HTTP 的动态自适应流(英语:Dynamic Adaptive Streaming over HTTP,缩写 DASH,也称 MPEG-DASH)是一种自适应比特率流技术,使高质量流媒体可以通过传统的 HTTP 网络服务器以互联网传递。」 类似苹果公司的 HTTP Live Streaming(HLS)方案,MPEG-DASH 会将内容分解成一系列小型的基于 HTTP 的文件片段,每个片段包含很短长度的可播放内容,而内容总长度可能长达数小时。

内容将被制成多种比特率的备选片段,以提供多种比特率的版本供选用。当内容被 MPEG-DASH 客户端回放时,客户端将根据当前网络条件自动选择下载和播放哪一个备选方案。客户端将选择可及时下载的最高比特率片段进行播放,从而避免播放卡顿或重新缓冲事件。也因如此,MPEG-DASH 客户端可以无缝适应不断变化的网络条件并提供高质量的播放体验,拥有更少的卡顿与重新缓冲发生率。

MPEG-DASH 是首个基于 HTTP 的自适应比特率流解决方案,它也是一项国际标准。MPEG-DASH 不应该与传输协议混淆 —— MPEG-DASH 使用 TCP 传输协议。「不同于 HLS、HDS 和 Smooth Streaming,DASH 不关心编解码器,因此它可以接受任何编码格式编码的内容,如 H.265、H.264、VP9 等。」

虽然 HTML5 不直接支持 MPEG-DASH,但是已有一些 MPEG-DASH 的 JavaScript 实现允许在网页浏览器中通过 HTML5 Media Source Extensions(MSE)使用 MPEG-DASH。另有其他 JavaScript 实现,如 bitdash 播放器支持使用 HTML5 加密媒体扩展播放有 DRM 的MPEG-DASH。当与 WebGL 结合使用,MPEG-DASH 基于 HTML5 的自适应比特率流还可实现 360° 视频的实时和按需的高效流式传输。

4.2 DASH 重要概念

  • MPD:媒体文件的描述文件(manifest),作用类似 HLS 的 m3u8 文件。
  • Representation:对应一个可选择的输出(alternative)。如 480p 视频,720p 视频,44100 采样音频等都使用 Representation 描述。
  • Segment(分片):每个 Representation 会划分为多个 Segment。Segment 分为 4 类,其中,最重要的是:Initialization Segment(每个 Representation 都包含 1 个 Init Segment),Media Segment(每个 Representation 的媒体内容包含若干 Media Segment)。

(图片来源:https://blog.csdn.net/yue_hua...

在国内 Bilibili 于 2018 年开始使用 DASH 技术,至于为什么选择 DASH 技术。感兴趣的小伙伴可以阅读 我们为什么使用DASH 这篇文章。

讲了那么多,相信有些小伙伴会好奇 MPD 文件长什么样?这里我们来看一下西瓜视频播放器 DASH 示例中的 MPD 文件:

<?xml version="1.0"?>
<!-- MPD file Generated with GPAC version 0.7.2-DEV-rev559-g61a50f45-master  at 2018-06-11T11:40:23.972Z-->
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" minBufferTime="PT1.500S" type="static" mediaPresentationDuration="PT0H1M30.080S" maxSegmentDuration="PT0H0M1.000S" profiles="urn:mpeg:dash:profile:full:2011">
 <ProgramInformation moreInformationURL="http://gpac.io">
  <Title>xgplayer-demo_dash.mpd generated by GPAC</Title>
 </ProgramInformation>
 <Period duration="PT0H1M30.080S">
  <AdaptationSet segmentAlignment="true" maxWidth="1280" maxHeight="720" maxFrameRate="25" par="16:9" lang="eng">
   <ContentComponent id="1" contentType="audio" />
   <ContentComponent id="2" contentType="video" />
   <Representation id="1" mimeType="video/mp4" codecs="mp4a.40.2,avc3.4D4020" width="1280" height="720" frameRate="25" sar="1:1" startWithSAP="0" bandwidth="6046495">
    <AudioChannelConfiguration schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011" value="2"/>
    <BaseURL>xgplayer-demo_dashinit.mp4</BaseURL>
    <SegmentList timescale="1000" duration="1000">
     <Initialization range="0-1256"/>
      <SegmentURL mediaRange="1257-1006330" indexRange="1257-1300"/>
      <SegmentURL mediaRange="1006331-1909476" indexRange="1006331-1006374"/>
      ...
      <SegmentURL mediaRange="68082016-68083543" indexRange="68082016-68082059"/>
    </SegmentList>
   </Representation>
  </AdaptationSet>
 </Period>
</MPD>

(文件来源:https://h5player.bytedance.co...

在播放视频时,西瓜视频播放器会根据 MPD 文件,自动请求对应的分片进行播放。

前面我们已经提到了 Bilibili,接下来不得不提其开源的一个著名的开源项目 —— flv.js,不过在介绍它之前我们需要来了解一下 FLV 流媒体格式。

五、FLV

5.1 FLV 文件结构

FLV 是 FLASH Video 的简称,FLV 流媒体格式是随着 Flash MX 的推出发展而来的视频格式。由于它形成的文件极小、加载速度极快,使得网络观看视频文件成为可能,它的出现有效地解决了视频文件导入 Flash 后,使导出的 SWF 文件体积庞大,不能在网络上很好的使用等问题。

FLV 文件由 FLV Header 和 FLV Body 两部分构成,而 FLV Body 由一系列的 Tag 构成:

5.1.1 FLV 头文件

FLV 头文件:(9 字节)

  • 1-3:前 3 个字节是文件格式标识(FLV 0x46 0x4C 0x56)。
  • 4-4:第 4 个字节是版本(0x01)。
  • 5-5:第 5 个字节的前 5 个 bit 是保留的必须是 0。

    • 第 5 个字节的第 6 个 bit 音频类型标志(TypeFlagsAudio)。
    • 第 5 个字节的第 7 个 bit 也是保留的必须是 0。
    • 第5个字节的第8个bit视频类型标志(TypeFlagsVideo)。
  • 6-9: 第 6-9 的四个字节还是保留的,其数据为 00000009。
  • 整个文件头的长度,一般是 9(3+1+1+4)。

5.1.2 tag 基本格式

tag 类型信息,固定长度为 15 字节:

  • 1-4:前一个 tag 长度(4字节),第一个 tag 就是 0。
  • 5-5:tag 类型(1 字节);0x8 音频;0x9 视频;0x12 脚本数据。
  • 6-8:tag 内容大小(3 字节)。
  • 9-11:时间戳(3 字节,毫秒)(第 1 个 tag 的时候总是为 0,如果是脚本 tag 就是 0)。
  • 12-12:时间戳扩展(1 字节)让时间戳变成 4 字节(以存储更长时间的 flv 时间信息),本字节作为时间戳的最高位。

在 flv 回放过程中,播放顺序是按照 tag 的时间戳顺序播放。任何加入到文件中时间设置数据格式都将被忽略。

  • 13-15:streamID(3 字节)总是 0。

FLV 格式详细的结构图如下图所示:

在浏览器中 HTML5 的 <video> 是不支持直接播放 FLV 视频格式,需要借助 flv.js 这个开源库来实现播放 FLV 视频格式的功能。

5.2 flv.js 简介

flv.js 是用纯 JavaScript 编写的 HTML5 Flash Video(FLV)播放器,它底层依赖于 Media Source Extensions。在实际运行过程中,它会自动解析 FLV 格式文件并喂给原生 HTML5 Video 标签播放音视频数据,使浏览器在不借助 Flash 的情况下播放 FLV 成为可能。

5.2.1 flv.js 的特性

  • 支持播放 H.264 + AAC / MP3 编码的 FLV 文件;
  • 支持播放多段分段视频;
  • 支持播放 HTTP FLV 低延迟实时流;
  • 支持播放基于 WebSocket 传输的 FLV 实时流;
  • 兼容 Chrome,FireFox,Safari 10,IE11 和 Edge;
  • 极低的开销,支持浏览器的硬件加速。

5.2.2 flv.js 的限制

  • MP3 音频编解码器无法在 IE11/Edge 上运行;
  • HTTP FLV 直播流不支持所有的浏览器。

5.2.3 flv.js 的使用

<script data-original="flv.min.js"></script>
<video id="videoElement"></video>
<script> if (flvjs.isSupported()) {
        var videoElement = document.getElementById('videoElement');
        var flvPlayer = flvjs.createPlayer({
            type: 'flv',
            url: 'http://example.com/flv/video.flv'
        });
        flvPlayer.attachMediaElement(videoElement);
        flvPlayer.load();
        flvPlayer.play();
    }</script>

5.3 flv.js 工作原理

flv.js 的工作原理是将 FLV 文件流转换为 ISO BMFF(Fragmented MP4)片段,然后通过 Media Source Extensions API 将 mp4 段喂给 HTML5 <video> 元素。flv.js 的设计架构图如下图所示:

(图片来源:https://github.com/bilibili/f...

有关 flv.js 工作原理更详细的介绍,感兴趣的小伙们可以阅读 花椒开源项目实时互动流媒体播放器 这篇文章。现在我们已经介绍了 hls.js 和 flv.js 这两个主流的流媒体解决方案,其实它们的成功离不开 Media Source Extensions 这个幕后英雄默默地支持。因此,接下来阿宝哥将带大家一起认识一下 MSE(Media Source Extensions)。

六、MSE

6.1 MSE API

媒体源扩展 API(Media Source Extensions) 提供了实现无插件且基于 Web 的流媒体的功能。使用 MSE,媒体串流能够通过 JavaScript 创建,并且能通过使用 audiovideo 元素进行播放。

近几年来,我们已经可以在 Web 应用程序上无插件地播放视频和音频了。但是,现有架构过于简单,只能满足一次播放整个曲目的需要,无法实现拆分/合并数个缓冲文件。早期的流媒体主要使用 Flash 进行服务,以及通过 RTMP 协议进行视频串流的 Flash 媒体服务器。

媒体源扩展(MSE)实现后,情况就不一样了。MSE 使我们可以把通常的单个媒体文件的 src 值替换成引用 MediaSource 对象(一个包含即将播放的媒体文件的准备状态等信息的容器),以及引用多个 SourceBuffer 对象(代表多个组成整个串流的不同媒体块)的元素。

为了便于大家理解,我们来看一下基础的 MSE 数据流:

MSE 让我们能够根据内容获取的大小和频率,或是内存占用详情(例如什么时候缓存被回收),进行更加精准地控制。它是基于它可扩展的 API 建立自适应比特率流客户端(例如 DASH 或 HLS 的客户端)的基础。

在现代浏览器中创造能兼容 MSE 的媒体非常费时费力,还要消耗大量计算机资源和能源。此外,还须使用外部应用程序将内容转换成合适的格式。虽然浏览器支持兼容 MSE 的各种媒体容器,但采用 H.264 视频编码、AAC 音频编码和 MP4 容器的格式是非常常见的,所以 MSE 需要兼容这些主流的格式。此外 MSE 还为开发者提供了一个 API,用于运行时检测容器和编解码是否受支持。

6.2 MediaSource 接口

MediaSource 是 Media Source Extensions API 表示媒体资源 HTMLMediaElement 对象的接口。MediaSource 对象可以附着在 HTMLMediaElement 在客户端进行播放。在介绍 MediaSource 接口前,我们先来看一下它的结构图:

(图片来源 —— https://www.w3.org/TR/media-s...

要理解 MediaSource 的结构图,我们得先来介绍一下客户端音视频播放器播放一个视频流的主要流程:

获取流媒体 -> 解协议 -> 解封装 -> 音、视频解码 -> 音频播放及视频渲染(需处理音视频同步)。

由于采集的原始音视频数据比较大,为了方便网络传输,我们通常会使用编码器,如常见的 H.264 或 AAC 来压缩原始媒体信号。最常见的媒体信号是视频,音频和字幕。比如,日常生活中的电影,就是由不同的媒体信号组成,除运动图片外,大多数电影还含有音频和字幕。

常见的视频编解码器有:H.264,HEVC,VP9 和 AV1。而音频编解码器有:AAC,MP3 或 Opus。每个媒体信号都有许多不同的编解码器。下面我们以西瓜视频播放器的 Demo 为例,来直观感受一下音频轨、视频轨和字幕轨:

现在我们来开始介绍 MediaSource 接口的相关内容。

6.2.1 状态

enum ReadyState {
    "closed", // 指示当前源未附加到媒体元素。
    "open", // 源已经被媒体元素打开,数据即将被添加到SourceBuffer对象中
    "ended" // 源仍附加到媒体元素,但endOfStream()已被调用。
};

6.2.2 流终止异常

enum EndOfStreamError {
    "network", // 终止播放并发出网络错误信号。
    "decode" // 终止播放并发出解码错误信号。
};

6.2.3 构造器

[Constructor]
interface MediaSource : EventTarget {
    readonly attribute SourceBufferList    sourceBuffers;
    readonly attribute SourceBufferList    activeSourceBuffers;
    readonly attribute ReadyState          readyState;
             attribute unrestricted double duration;
             attribute EventHandler        onsourceopen;
             attribute EventHandler        onsourceended;
             attribute EventHandler        onsourceclose;
  
    SourceBuffer addSourceBuffer(DOMString type);
    void         removeSourceBuffer(SourceBuffer sourceBuffer);
    void         endOfStream(optional EndOfStreamError error);
    void         setLiveSeekableRange(double start, double end);
    void         clearLiveSeekableRange();
    static boolean isTypeSupported(DOMString type);
};

6.2.4 属性

  • MediaSource.sourceBuffers —— 只读:返回一个 SourceBufferList 对象,包含了这个 MediaSource 的SourceBuffer 的对象列表。
  • MediaSource.activeSourceBuffers —— 只读:返回一个 SourceBufferList 对象,包含了这个MediaSource.sourceBuffers 中的 SourceBuffer 子集的对象—即提供当前被选中的视频轨(video track),启用的音频轨(audio tracks)以及显示/隐藏的字幕轨(text tracks)的对象列表
  • MediaSource.readyState —— 只读:返回一个包含当前 MediaSource 状态的集合,即使它当前没有附着到一个 media 元素(closed),或者已附着并准备接收 SourceBuffer 对象(open),亦或者已附着但这个流已被 MediaSource.endOfStream() 关闭。
  • MediaSource.duration:获取和设置当前正在推流媒体的持续时间。
  • onsourceopen:设置 sourceopen 事件对应的事件处理程序。
  • onsourceended:设置 sourceended 事件对应的事件处理程序。
  • onsourceclose:设置 sourceclose 事件对应的事件处理程序。

6.2.5 方法

  • MediaSource.addSourceBuffer():创建一个带有给定 MIME 类型的新的 SourceBuffer 并添加到 MediaSource 的 SourceBuffers 列表。
  • MediaSource.removeSourceBuffer():删除指定的 SourceBuffer 从这个 MediaSource 对象中的 SourceBuffers 列表。
  • MediaSource.endOfStream():表示流的结束。

6.2.6 静态方法

  • MediaSource.isTypeSupported():返回一个 Boolean 值表明给定的 MIME 类型是否被当前的浏览器支持—— 这意味着是否可以成功的创建这个 MIME 类型的 SourceBuffer 对象。

6.2.7 使用示例

var vidElement = document.querySelector('video');
if (window.MediaSource) { // (1)
  var mediaSource = new MediaSource();
  vidElement.src = URL.createObjectURL(mediaSource);
  mediaSource.addEventListener('sourceopen', sourceOpen); 
} else {
  console.log("The Media Source Extensions API is not supported.")
}
function sourceOpen(e) {
  URL.revokeObjectURL(vidElement.src);
  var mime = 'video/mp4; codecs="avc1.42E01E, mp4a.40.2"';
  var mediaSource = e.target;
  var sourceBuffer = mediaSource.addSourceBuffer(mime); // (2)
  var videoUrl = 'hello-mse.mp4';
  fetch(videoUrl) // (3)
    .then(function(response) {
      return response.arrayBuffer();
    })
    .then(function(arrayBuffer) {
      sourceBuffer.addEventListener('updateend', function(e) { (4)
        if (!sourceBuffer.updating && mediaSource.readyState === 'open') {
          mediaSource.endOfStream(); 
        }
      });
      sourceBuffer.appendBuffer(arrayBuffer); // (5)
    });
}

以上示例介绍了如何使用 MSE API,接下来我们来分析一下主要的工作流程:

  • (1) 判断当前平台是否支持 Media Source Extensions API,若支持的话,则创建 MediaSource 对象,且绑定 sourceopen 事件处理函数。
  • (2) 创建一个带有给定 MIME 类型的新的 SourceBuffer 并添加到 MediaSource 的 SourceBuffers 列表。
  • (3) 从远程流服务器下载视频流,并转换成 ArrayBuffer 对象。
  • (4) 为 sourceBuffer 对象添加 updateend 事件处理函数,在视频流传输完成后关闭流。
  • (5) 往 sourceBuffer 对象中添加已转换的 ArrayBuffer 格式的视频流数据。

上面只是简单介绍了一下 MSE API,想深入了解它实际应用的小伙伴,可以进一步了解一下 「hls.js」「flv.js」 项目。接下来阿宝哥将介绍音视频基础之多媒体容器格式。

七、多媒体封装格式

一般情况下,一个完整的视频文件是由音频和视频两部分组成的。常见的 AVI、RMVB、MKV、ASF、WMV、MP4、3GP、FLV 等文件只能算是一种封装格式。H.264,HEVC,VP9 和 AV1 等就是视频编码格式,MP3、AAC 和 Opus 等就是音频编码格式。「比如:将一个 H.264 视频编码文件和一个 AAC 音频编码文件按 MP4 封装标准封装以后,就得到一个 MP4 后缀的视频文件,也就是我们常见的 MP4 视频文件了。」

音视频编码的主要目的是压缩原始数据的体积,而封装格式(也称为多媒体容器),比如 MP4,MKV,是用来存储/传输编码数据,并按一定规则把音视频、字幕等数据组织起来,同时还会包含一些元信息,比如当前流中包含哪些编码类型、时间戳等,播放器可以按照这些信息来匹配解码器、同步音视频。

为了能更好地理解多媒体封装格式,我们再来回顾一下视频播放器的原理。

7.1 视频播放器原理

视频播放器是指能播放以数字信号形式存储的视频的软件,也指具有播放视频功能的电子器件产品。大多数视频播放器(除了少数波形文件外)携带解码器以还原经过压缩的媒体文件,视频播放器还要内置一整套转换频率以及缓冲的算法。大多数的视频播放器还能支持播放音频文件。

视频播放基本处理流程大致包括以下几个阶段:

(1)解协议

从原始的流媒体协议数据中删除信令数据,只保留音视频数据,如采用 RTMP 协议传输的数据,经过解协议后输出 flv 格式的数据。

(2)解封装

分离音频和视频压缩编码数据,常见的封装格式 MP4,MKV,RMVB,FLV,AVI 这些格式。从而将已经压缩编码的视频、音频数据放到一起。例如 FLV 格式的数据经过解封装后输出 H.264 编码的视频码流和 AAC 编码的音频码流。

(3)解码

视频,音频压缩编码数据,还原成非压缩的视频,音频原始数据,音频的压缩编码标准包括 AAC,MP3,AC-3 等,视频压缩编码标准包含 H.264,MPEG2,VC-1 等经过解码得到非压缩的视频颜色数据如 YUV420P,RGB 和非压缩的音频数据如 PCM 等。

(4)音视频同步

将同步解码出来的音频和视频数据分别送至系统声卡和显卡播放。

了解完视频播放器的原理,下一步我们来介绍多媒体封装格式。

7.2 多媒体封装格式

对于数字媒体数据来说,容器就是一个可以将多媒体数据混在一起存放的东西,就像是一个包装箱,它可以对音、视频数据进行打包装箱,将原来的两块独立的媒体数据整合到一起,当然也可以单单只存放一种类型的媒体数据。

「有时候,多媒体容器也称封装格式,它只是为编码后的多媒体数据提供了一个 “外壳”,也就是将所有的处理好的音频、视频或字幕都包装到一个文件容器内呈现给观众,这个包装的过程就叫封装。」 常用的封装格式有:MP4,MOV,TS,FLV,MKV 等。这里我们来介绍大家比较熟悉的 MP4 封装格式。

7.2.1 MP4 封装格式

MPEG-4 Part 14(MP4)是最常用的容器格式之一,通常以 .mp4 文件结尾。它用于 HTTP(DASH)上的动态自适应流,也可以用于 Apple 的 HLS 流。MP4 基于 ISO 基本媒体文件格式(MPEG-4 Part 12),该格式基于 QuickTime 文件格式。MPEG 代表动态图像专家组,是国际标准化组织(ISO)和国际电工委员会(IEC)的合作。MPEG 的成立是为了设置音频和视频压缩与传输的标准。

MP4 支持多种编解码器,常用的视频编解码器是 H.264 和 HEVC,而常用的音频编解码器是 AAC,AAC 是著名的 MP3 音频编解码器的后继产品。

MP4 是由一些列的 box 组成,它的最小组成单元是 box。MP4 文件中的所有数据都装在 box 中,即 MP4 文件由若干个 box 组成,每个 box 有类型和长度,可以将 box 理解为一个数据对象块。box 中可以包含另一个 box,这种 box 称为 container box。

一个 MP4 文件首先会有且仅有 一个 ftype 类型的 box,作为 MP4 格式的标志并包含关于文件的一些信息,之后会有且只有一个 moov 类型的 box(movie box),它是一种 container box,可以有多个,也可以没有,媒体数据的结构由 metadata 进行描述。

相信有些读者会有疑问 —— 实际的 MP4 文件结构是怎么样的?通过使用 mp4box.js 提供的在线服务,我们可以方便的查看本地或在线 MP4 文件内部的结构:

mp4box.js 在线地址:https://gpac.github.io/mp4box...

由于 MP4 文件结构比较复杂(不信请看下图),这里我们就不继续展开,有兴趣的读者,可以自行阅读相关文章。

接下来,我们来介绍 Fragmented MP4 容器格式。

7.2.2 Fragmented MP4 封装格式

MP4 ISO Base Media 文件格式标准允许以 fragmented 方式组织 box,这也就意味着 MP4 文件可以组织成这样的结构,由一系列的短的 metadata/data box 对组成,而不是一个长的 metadata/data 对。Fragmented MP4 文件结构如下图所示,图中只包含了两个 fragments:

(图片来源 —— https://alexzambelli.com/blog...

在 Fragmented MP4 文件中含有三个非常关键的 boxes:moovmoofmdat

  • moov(movie metadata box):用于存放多媒体 file-level 的元信息。
  • mdat(media data box):和普通 MP4 文件的 mdat 一样,用于存放媒体数据,不同的是普通 MP4 文件只有一个 mdat box,而 Fragmented MP4 文件中,每个 fragment 都会有一个 mdat 类型的 box。
  • moof(movie fragment box):用于存放 fragment-level 的元信息。该类型的 box 在普通的 MP4 文件中是不存在的,而在 Fragmented MP4 文件中,每个 fragment 都会有一个 moof 类型的 box。

Fragmented MP4 文件中的 fragment 由 moofmdat 两部分组成,每个 fragment 可以包含一个音频轨或视频轨,并且也会包含足够的元信息,以保证这部分数据可以单独解码。Fragment 的结构如下图所示:

(图片来源 —— https://alexzambelli.com/blog...

同样,利用 mp4box.js 提供的在线服务,我们也可以清晰的查看 Fragmented MP4 文件的内部结构:

我们已经介绍了 MP4 和 Fragmented MP4 这两种容器格式,我们用一张图来总结一下它们之间的主要区别:

八、实用示例

8.1 如何实现视频本地预览

视频本地预览的功能主要利用 URL.createObjectURL() 方法来实现。URL.createObjectURL() 静态方法会创建一个 DOMString,其中包含一个表示参数中给出的对象的 URL。这个 URL 的生命周期和创建它的窗口中的 document 绑定。这个新的 URL 对象表示指定的 File 对象或 Blob 对象。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>视频本地预览示例</title>
  </head>
  <body>
    <h3>阿宝哥:视频本地预览示例</h3>
    <input type="file" accept="video/*" onchange="loadFile(event)" />
    <video
      id="previewContainer"
      controls
      width="480"
      height="270"
      style="display: none;"
    ></video>
    <script> const loadFile = function (event) {
        const reader = new FileReader();
        reader.onload = function () {
          const output = document.querySelector("#previewContainer");
          output.style.display = "block";
          output.src = URL.createObjectURL(new Blob([reader.result]));
        };
        reader.readAsArrayBuffer(event.target.files[0]);
      }; </script>
  </body>
</html>

8.2 如何实现播放器截图

播放器截图功能主要利用 CanvasRenderingContext2D.drawImage() API 来实现。Canvas 2D API 中的 CanvasRenderingContext2D.drawImage()  方法提供了多种方式在 Canvas 上绘制图像。

drawImage API 的语法如下:

void ctx.drawImage(image, dx, dy); 

void ctx.drawImage(image, dx, dy, dWidth, dHeight); 

void ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

其中 image 参数表示绘制到上下文的元素。允许任何的 canvas 图像源(CanvasImageSource),例如:CSSImageValue,HTMLImageElement,SVGImageElement,HTMLVideoElement,HTMLCanvasElement,ImageBitmap 或者 OffscreenCanvas。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>播放器截图示例</title>
  </head>
  <body>
    <h3>阿宝哥:播放器截图示例</h3>
    <video id="video" controls="controls" width="460" height="270" crossorigin="anonymous">
      <!-- 请替换为实际视频地址 -->
      <source data-original="https://xxx.com/vid_159411468092581" />
    </video>
    <button onclick="captureVideo()">截图</button>
    <script> let video = document.querySelector("#video");
      let canvas = document.createElement("canvas");
      let img = document.createElement("img");
      img.crossOrigin = "";
      let ctx = canvas.getContext("2d");
      function captureVideo() {
        canvas.width = video.videoWidth;
        canvas.height = video.videoHeight;
        ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
        img.src = canvas.toDataURL();
        document.body.append(img);
      } </script>
  </body>
</html>

现在我们已经知道如何获取视频的每一帧,其实在结合 gif.js 这个库提供的 GIF 编码功能,我们就可以快速地实现截取视频帧生成 GIF 动画的功能。这里阿宝哥不继续展开介绍,有兴趣的小伙伴可以阅读 ”使用 JS 直接截取 视频片段 生成 gif 动画“ 这篇文章。

8.3 如何实现 Canvas 播放视频

使用 Canvas 播放视频主要是利用 ctx.drawImage(video, x, y, width, height) 来对视频当前帧的图像进行绘制,其中 video 参数就是页面中的 video 对象。所以如果我们按照特定的频率不断获取 video 当前画面,并渲染到 Canvas 画布上,就可以实现使用 Canvas 播放视频的功能。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>使用 Canvas 播放视频</title>
  </head>
  <body>
    <h3>阿宝哥:使用 Canvas 播放视频</h3>
    <video id="video" controls="controls" style="display: none;">
      <!-- 请替换为实际视频地址 -->
      <source data-original="https://xxx.com/vid_159411468092581" />
    </video>
    <canvas
      id="myCanvas"
      width="460"
      height="270"
      style="border: 1px solid blue;"
    ></canvas>
    <div>
      <button id="playBtn">播放</button>
      <button id="pauseBtn">暂停</button>
    </div>
    <script> const video = document.querySelector("#video");
      const canvas = document.querySelector("#myCanvas");
      const playBtn = document.querySelector("#playBtn");
      const pauseBtn = document.querySelector("#pauseBtn");
      const context = canvas.getContext("2d");
      let timerId = null;
      function draw() {
        if (video.paused || video.ended) return;
        context.clearRect(0, 0, canvas.width, canvas.height);
        context.drawImage(video, 0, 0, canvas.width, canvas.height);
        timerId = setTimeout(draw, 0);
      }
      playBtn.addEventListener("click", () => {
        if (!video.paused) return;
        video.play();
        draw();
      });
      pauseBtn.addEventListener("click", () => {
        if (video.paused) return;
        video.pause();
        clearTimeout(timerId);
      }); </script>
  </body>
</html>

8.4 如何实现色度键控(绿屏效果)

上一个示例我们介绍了使用 Canvas 播放视频,那么可能有一些小伙伴会有疑问,为什么要通过 Canvas 绘制视频,Video 标签不 “香” 么?这是因为 Canvas 提供了 getImageDataputImageData 方法使得开发者可以动态地更改每一帧图像的显示内容。这样的话,我们就可以实时地操纵视频数据来合成各种视觉特效到正在呈现的视频画面中。

比如 MDN 上的 ”使用 canvas 处理视频“ 的教程中就演示了如何使用 JavaScript 代码执行色度键控(绿屏或蓝屏效果)。

所谓的色度键控,又称色彩嵌空,是一种去背合成技术。Chroma 为纯色之意,Key 则是抽离颜色之意。把被拍摄的人物或物体放置于绿幕的前面,并进行去背后,将其替换成其他的背景。此技术在电影、电视剧及游戏制作中被大量使用,色键也是虚拟摄影棚(Virtual studio)与视觉效果(Visual effects)当中的一个重要环节。

下面我们来看一下关键代码:

processor.computeFrame = function computeFrame() {
    this.ctx1.drawImage(this.video, 0, 0, this.width, this.height);
    let frame = this.ctx1.getImageData(0, 0, this.width, this.height);
    let l = frame.data.length / 4;
    for (let i = 0; i < l; i++) {
      let r = frame.data[i * 4 + 0];
      let g = frame.data[i * 4 + 1];
      let b = frame.data[i * 4 + 2];
      if (g > 100 && r > 100 && b < 43)
        frame.data[i * 4 + 3] = 0;
    }
    this.ctx2.putImageData(frame, 0, 0);
    return;
}

以上的 computeFrame() 方法负责获取一帧数据并执行色度键控效果。利用色度键控技术,我们还可以实现纯客户端实时蒙版弹幕。

查看原文

赞 26 收藏 17 评论 0

万年打野易大师 发布了文章 · 2020-12-08

文件上传到底怎么回事

前言

平常在写业务的时候常常会用的到的是 GET, POST请求去请求接口,GET 相关的接口会比较容易基本不会出错,而对于 POST中常用的 表单提交,JSON提交也比较容易,但是对于文件上传呢?大家可能对这个步骤会比较害怕,因为可能大家对它并不是怎么熟悉,而浏览器Network对它也没有详细的进行记录,因此它成为了我们心中的一根刺,我们老是无法确定,关于文件上传到底是我写的有问题呢?还是后端有问题,当然,我们一般都比较谦虚, 总是会在自己身上找原因,可是往往实事呢?可能就出在后端身上,可能是他接受写的有问题,导致你换了各种请求库去尝试,axiosrequestfetch 等等。那么我们如何避免这种情况呢?我们自身要对这一块够熟悉,才能不以猜的方式去写代码。如果你觉得我以上说的你有同感,那么你阅读完这篇文章你将收获自信,你将不会质疑自己,不会以猜的方式去写代码。

本文比较长可能需要花点时间去看,需要有耐心,我采用自顶向下的方式,所有示例会先展现出你熟悉的方式,再一层层往下, 先从请求端是怎么发送文件的,再到接收端是怎么解析文件的。

前置知识

什么是 multipart/form-data?

multipart/form-data 最初由 《RFC 1867: Form-based File Upload in HTML》[1]文档提出。

Since file-upload is a feature that will benefit many applications, this proposes an extension to HTML to allow information providers to express file upload requests uniformly, and a MIME compatible representation for file upload responses.

由于文件上传功能将使许多应用程序受益,因此建议对HTML进行扩展,以允许信息提供者统一表达文件上传请求,并提供文件上传响应的MIME兼容表示。

总结就是原先的规范不满足啦,我要扩充规范了。

文件上传为什么要用 multipart/form-data?

The encoding type application/x-www-form-urlencoded is inefficient for sending large quantities of binary data or text containing non-ASCII characters.  Thus, a new media type,multipart/form-data, is proposed as a way of efficiently sending the values associated with a filled-out form from client to server.

1867文档中也写了为什么要新增一个类型,而不使用旧有的application/x-www-form-urlencoded:因为此类型不适合用于传输大型二进制数据或者包含非ASCII字符的数据。平常我们使用这个类型都是把表单数据使用url编码后传送给后端,二进制文件当然没办法一起编码进去了。所以multipart/form-data就诞生了,专门用于有效的传输文件。

也许你有疑问?那可以用 application/json吗?

其实我认为,无论你用什么都可以传,只不过会要综合考虑一些因素的话,multipart/form-data更好。例如我们知道了文件是以二进制的形式存在,application/json 是以文本形式进行传输,那么某种意义上我们确实可以将文件转成例如文本形式的 Base64 形式。但是呢,你转成这样的形式,后端也需要按照你这样传输的形式,做特殊的解析。并且文本在传输过程中是相比二进制效率低的,那么对于我们动辄几十M几百M的文件来说是速度是更慢的。

以上为什么文件传输要用multipart/form-data 我还可以举个例子,例如你在中国,你想要去美洲,我们的multipart/form-data相当于是选择飞机,而application/json相当于高铁,但是呢?中国和美洲之间没有高铁啊,你执意要坐高铁去,你可以花昂贵的代价(后端额外解析你的文本)造高铁去美洲,但是你有更加廉价的方式坐飞机(使用multipart/form-data)去美洲(去传输文件)。你图啥?(如果你有钱有时间,抱歉,打扰了,老子给你道歉)

multipart/form-data规范是什么?

摘自 《RFC 1867: Form-based File Upload in HTML》[2] 6.Example

Content-type: multipart/form-data, boundary=AaB03x
--AaB03x
content-disposition: form-data; name="field1"
Joe Blow
--AaB03x
content-disposition: form-data; name="pics"; filename="file1.txt"
Content-Type: text/plain
... contents of file1.txt ...
--AaB03x--

可以简单解释一些,首先是请求类型,然后是一个 boundary (分割符),这个东西是干啥的呢?其实看名字就知道,分隔符,当时分割作用,因为可能有多文件多字段,每个字段文件之间,我们无法准确地去判断这个文件哪里到哪里为截止状态。因此需要有分隔符来进行划分。然后再接下来就是声明内容的描述是 form-data 类型,字段名字是啥,如果是文件的话,得知道文件名是啥,还有这个文件的类型是啥,这个也很好理解,我上传一个文件,我总得告诉后端,我传的是个啥,是图片?还是一个txt文本?这些信息肯定得告诉人家,别人才好去进行判断,后面我们也会讲到如果这些没有声明的时候,会发生什么?

好了讲完了这些前置知识,我们接下来要进入我们的主题了。面对 File, formData,Blob,Base64,ArrayBuffer 到底怎么做?还有文件上传不仅仅是前端的事。服务端也可以文件上传(例如我们利用某云,把静态资源上传到 OSS 对象存储)。服务端和客户端也有各种类型,Buffer,Stream,Base64....头秃,怎么搞?不急,就是因为上传文件不单单是前端的事,所以我将以下上传文件的一方称为请求端,接受文件一方称为接收方。我会以请求端各种上传方式,接收端是怎么解析我们的文件以及我们最终的杀手锏调试工具 -wireshark 来进行讲解。以下是讲解的大纲,我们先从浏览器端上传文件,再到服务端上传文件,然后我们再来解析文件是如何被解析的。

请求端

前端

File

首先我们先写下最简单的一个表单提交方式。

<form action="http://localhost:7787/files" method="POST">
 <input name="file" type="file" id="file">
 <input type="submit" value="提交">
</form>

我们选择文件后上传,发现后端返回了文件不存在。

不用着急,熟悉的同学可能立马知道是啥原因了。嘘,知道了也听我慢慢叨叨。

我们打开控制台,由于表单提交会进行网页跳转,因此我们勾选preserve log 来进行日志追踪。

我们可以发现其实 FormDatafile 字段显示的是文件名,并没有将真正的内容进行传输。再看请求头。

发现是请求头和预期不符,也印证了 application/x-www-form-urlencoded 无法进行文件上传。

我们加上请求头,再次请求。

<form action="http://localhost:7787/files" enctype="multipart/form-data" method="POST">
 <input name="file" type="file" id="file">
 <input type="submit" value="提交">
</form>

发现文件上传成功,简单的表单上传就是像以上一样简单。但是你得熟记文件上传的格式以及类型。

FormData

formData 的方式我随便写了以下几种方式。

<input type="file" id="file">
<button id="submit">上传</button>
<script data-original="https://cdn.bootcss.com/axios/0.19.2/axios.min.js"></script>
<script>
submit.onclick = () => {
 const file = document.getElementById('file').files[0];
 var form = new FormData();
 form.append('file', file);
 
 // type 1
 axios.post('http://localhost:7787/files', form).then(res => {
 console.log(res.data);
 })
 // type 2
 fetch('http://localhost:7787/files', {
 method: 'POST',
 body: form
 }).then(res => res.json()).tehn(res => {console.log(res)});
 // type3
 var xhr = new XMLHttpRequest();
 xhr.open('POST', 'http://localhost:7787/files', true);
 xhr.onload = function () {
 console.log(xhr.responseText);
 };
 xhr.send(form);
}
</script>

以上几种方式都是可以的。但是呢,请求库这么多,我随便在 npm 上一搜就有几百个请求相关的库。

因此,掌握请求库的写法并不是我们的目标,目标只有一个还是掌握文件上传的请求头和请求内容。

Blob

Blob 对象表示一个不可变、原始数据的类文件对象。Blob 表示的不一定是JavaScript原生格式的数据。File[3] 接口基于Blob,继承了 blob 的功能并将其扩展使其支持用户系统上的文件。

因此如果我们遇到 Blob 方式的文件上方式不用害怕,可以用以下两种方式:

1.直接使用 blob 上传

const json = { hello: "world" };
const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' });
 
const form = new FormData();
form.append('file', blob, '1.json');
axios.post('http://localhost:7787/files', form);

2.使用 File 对象,再进行一次包装(File 兼容性可能会差一些  https://caniuse.com/#search=File

const json = { hello: "world" };
const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' });
 
const file = new File([blob], '1.json');
form.append('file', file);
axios.post('http://localhost:7787/files', form)

ArrayBuffer

ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区。

虽然它用的比较少,但是他是最贴近文件流的方式了。

在浏览器中,他每个字节以十进制的方式存在。我提前准备了一张图片。

const bufferArrary = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,1,3,0,0,0,37,219,86,202,0,0,0,6,80,76,84,69,0,0,255,128,128,128,76,108,191,213,0,0,0,9,112,72,89,115,0,0,14,196,0,0,14,196,1,149,43,14,27,0,0,0,10,73,68,65,84,8,153,99,96,0,0,0,2,0,1,244,113,100,166,0,0,0,0,73,69,78,68,174,66,96,130];
const array = Uint8Array.from(bufferArrary);
const blob = new Blob([array], {type: 'image/png'});
const form = new FormData();
form.append('file', blob, '1.png');
axios.post('http://localhost:7787/files', form)

这里需要注意的是 new Blob([typedArray.buffer], {type: 'xxx'}),第一个参数是由一个数组包裹。里面是 typedArray 类型的 buffer。

Base64

const base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAABlBMVEUAAP+AgIBMbL/VAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAACklEQVQImWNgAAAAAgAB9HFkpgAAAABJRU5ErkJggg==';
const byteCharacters = atob(base64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
 byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const array = Uint8Array.from(byteNumbers);
const blob = new Blob([array], {type: 'image/png'});
const form = new FormData();
form.append('file', blob, '1.png');
axios.post('http://localhost:7787/files', form);

关于 base64 的转化和原理可以看这两篇 base64 原理[4] 和

原来浏览器原生支持JS Base64编码解码[5]

小结

对于浏览器端的文件上传,可以归结出一个套路,所有东西核心思路就是构造出 File 对象。然后观察请求 Content-Type,再看请求体是否有信息缺失。而以上这些二进制数据类型的转化可以看以下表。

图片来源 (https://shanyue.tech/post/bin...[6])

服务端

讲完了浏览器端,现在我们来讲服务器端,和浏览器不同的是,服务端上传有两个难点。

1.浏览器没有原生 formData,也不会想浏览器一样帮我们转成二进制形式。

2.服务端没有可视化的 Network 调试器。

Buffer

Request

首先我们通过最简单的示例来进行演示,然后一步一步深入。相信文档可以查看 https://github.com/request/re...

// request-error.js
const fs = require('fs');
const path = require('path');
const request = require('request');
const stream = fs.readFileSync(path.join(__dirname, '../1.png'));
request.post({
 url: 'http://localhost:7787/files',
 formData: {
 file: stream,
 }
}, (err, res, body) => {
 console.log(body);
})

发现报了一个错误,正像上面所说,浏览器端报错,可以用NetWork。那么服务端怎么办?这个时候我们拿出我们的利器 -- wireshark

我们打开 wireshark (如果没有或者不会的可以查看教程 https://blog.csdn.net/u013613...

设置配置 tcp.port == 7787,这个是我们后端的端口。

运行上述文件 node request-error.js

我们来找到我们发送的这条http的请求报文。中间那堆乱七八糟的就是我们的文件内容。

POST /files HTTP/1.1
host: localhost:7787
content-type: multipart/form-data; boundary=--------------------------437240798074408070374415
content-length: 305
Connection: close
----------------------------437240798074408070374415
Content-Disposition: form-data; name="file"
Content-Type: application/octet-stream
.PNG
.
...
IHDR.............%.V.....PLTE......Ll.....    pHYs..........+.....
IDAT..c`.......qd.....IEND.B`.
----------------------------437240798074408070374415--

可以看到上述报文。发现我们的内容请求头 Content-Type: application/octet-stream有错误,我们上传的是图片请求头应该是image/png,并且也少了 filename="1.png"

我们来思考一下,我们刚才用的是fs.readFileSync(path.join(__dirname, '../1.png')) 这个函数返回的是 BufferBuffer是什么样的呢?就是下面的形式,不会包含任何文件相关的信息,只有二进制流。

<Buffer 01 02>

所以我想到的是,需要指定文件名以及文件格式,幸好 request 也给我们提供了这个选项。

key: {
 value:  fs.createReadStream('/dev/urandom'),
 options: {
 filename: 'topsecret.jpg',
 contentType: 'image/jpeg'
 }
}

可以指定options,因此正确的代码应该如下(省略不重要的代码)

...
request.post({
 url: 'http://localhost:7787/files',
 formData: {
 file: {
 value: stream,
 options: {
 filename: '1.png'
 }
 },
 }
});

我们通过抓包可以进行分析到,文件上传的要点还是规范,大部分的问题,都可以通过规范模板来进行排查,是否构造出了规范的样子。

Form-data

我们再深入一些,来看看 request 的源码, 他是怎么实现Node端的数据传输的。

打开源码我们很容易地就可以找到关于 formData 这块相关的内容 https://github.com/request/re...

就是利用form-data,我们先来看看 formData 的方式。

const path = require('path');
const FormData = require('form-data');
const fs = require('fs');
const http = require('http');
const form = new FormData();
form.append('file', fs.readFileSync(path.join(__dirname, '../1.png')), {
 filename: '1.png',
 contentType: 'image/jpeg',
});
const request = http.request({
 method: 'post',
 host: 'localhost',
 port: '7787',
 path: '/files',
 headers: form.getHeaders()
});
form.pipe(request);
request.on('response', function(res) {
 console.log(res.statusCode);
});

原生 Node

看完formData,可能感觉这个封装还是太高层了,于是我打算对照规范手动来构造multipart/form-data请求方式来进行讲解。我们再来回顾一下规范。

Content-type: multipart/form-data, boundary=AaB03x
--AaB03x
content-disposition: form-data; name="field1"
Joe Blow
--AaB03x
content-disposition: form-data; name="pics"; filename="file1.txt"
Content-Type: text/plain
... contents of file1.txt ...
--AaB03x--

我模拟上方,我用原生 Node 写出了一个multipart/form-data 请求的方式。

主要分为4个部分
* 构造请求header
* 构造内容header
* 写入内容
* 写入结束分隔符
const path = require('path');
const fs = require('fs');
const http = require('http');
// 定义一个分隔符,要确保唯一性
const boundaryKey = '-------------------------461591080941622511336662';
const request = http.request({
 method: 'post',
 host: 'localhost',
 port: '7787',
 path: '/files',
 headers: {
 'Content-Type': 'multipart/form-data; boundary=' + boundaryKey, // 在请求头上加上分隔符
 'Connection': 'keep-alive'
 }
});
// 写入内容头部
request.write(
 `--${boundaryKey}rnContent-Disposition: form-data; name="file"; filename="1.png"rnContent-Type: image/jpegrnrn`
);
// 写入内容
const fileStream = fs.createReadStream(path.join(__dirname, '../1.png'));
fileStream.pipe(request, { end: false });
fileStream.on('end', function () {
 // 写入尾部
 request.end('rn--' + boundaryKey + '--' + 'rn');
});
request.on('response', function(res) {
 console.log(res.statusCode);
});

至此,已经实现服务端上传文件的方式。

Stream、Base64

由于这两块就是和Buffer的转化,比较简单,我就不再重复描述了。可以作为留给大家的作业,感兴趣的可以给我这个示例代码仓库贡献这两个示例。

// base64 to buffer
const b64string = /* whatever */;
const buf = Buffer.from(b64string, 'base64');
// stream to buffer
function streamToBuffer(stream) { 
 return new Promise((resolve, reject) => {
 const buffers = [];
 stream.on('error', reject);
 stream.on('data', (data) => buffers.push(data))
 stream.on('end', () => resolve(Buffer.concat(buffers))
 });
}

小结

由于服务端没有像浏览器那样 formData 的原生对象,因此服务端核心思路为构造出文件上传的格式(header,filename等),然后写入 buffer 。然后千万别忘了用 wireshark进行验证。

接收端

这一部分是针对 Node 端进行讲解,对于那些 koa-body 等用惯了的同学,可能一样不太清楚整个过程发生了什么?可能唯一比较清楚的是 ctx.request.files ??? 如果ctx.request.files 不存在,就会懵逼了,可能也不太清楚它到底做了什么,文件流又是怎么解析的。

我还是要说到规范...请求端是按照规范来构造请求..那么我们接收端自然是按照规范来解析请求了。

Koa-body

const koaBody = require('koa-body');
app.use(koaBody({ multipart: true }));

我们来看看最常用的 koa-body,它的使用方式非常简单,短短几行,就能让我们享受到文件上传的简单与快乐(其他源码库一样的思路去寻找问题的本源) 可以带着一个问题去阅读,为什么用了它就能解析出文件?

寻求问题的本源,我们当然要打开 koa-body的源码,koa-body 源码很少只有211行,https://github.com/dlau/koa-b... 很容易地发现它其实是用了一个叫做formidable的库来解析files 的。并且把解析好的files 对象赋值到了 ctx.req.files。(所以说大家不要一味死记 ctx.request.files, 注意查看文档,因为今天用 koa-bodyctx.request.files 明天换个库可能就是 ctx.request.body 了)

因此看完koa-body我们得出的结论是,koa-body的核心方法是formidable

Formidable

那么让我们继续深入,来看看formidable做了什么,我们首先来看它的目录结构。

.
├── lib
│   ├── file.js
│   ├── incoming_form.js
│   ├── index.js
│   ├── json_parser.js
│   ├── multipart_parser.js
│   ├── octet_parser.js
│   └── querystring_parser.js

看到这个目录,我们大致可以梳理出这样的关系。

index.js
|
incoming_form.js
|
type
?
|
1.json_parser
2.multipart_parser
3.octet_parser
4.querystring_parser

由于源码分析比较枯燥。因此我只摘录比较重要的片段。由于我们是分析文件上传,所以我们只需要关心 multipart_parser 这个文件。

https://github.com/node-formi...

...
MultipartParser.prototype.write = function(buffer) {
 console.log(buffer);
 var self = this,
 i = 0,
 len = buffer.length,
 prevIndex = this.index,
 index = this.index,
 state = this.state,
...

我们将它的 buffer 打印看看.

<Buffer 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 34 36 31 35 39 31 30 38 30 39 34 31 36 32 32 35 31 31 33 33 36 36 36 ... >
144
<Buffer 89 50 4e 47 0d 0a 1a 0a 00 00 00 0d 49 48 44 52 00 00 00 01 00 00 00 01 01 03 00 00 00 25 db 56 ca 00 00 00 06 50 4c 54 45 00 00 ff 80 80 80 4c 6c bf ... >
106
<Buffer 0d 0a 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 2d 34 36 31 35 39 31 30 38 30 39 34 31 36 32 32 35 31 31 33 33 36 ... >

我们来看wireshark 抓到的包

我用红色进行了分割标记,对应的就是formidable所分割的片段 ,所以说这个包主要是将大段的 buffer 进行分割,然后循环处理。

这里我还可以补充一下,可能你对以上表非常陌生。左侧是二进制流,每1个代表1个字节,1字节=8位,上面的 2d 其实就是16进制的表示形式,用二进制表示就是 0010 1101,右侧是ascii 码用来可视化,但是 assii 分可显和非可显示。有部分是无法可视的。比如你所看到文件中有需要小点,就是不可见字符。

你可以对照,ascii表对照表[7]来看。

我来总结一下formidable对于文件的处理流程。

原生 Node

好了,我们已经知道了文件处理的流程,那么我们自己来写一个吧。

const fs = require('fs');
const http = require('http');
const querystring = require('querystring');
const server = http.createServer((req, res) => {
    if (req.url === "/files" && req.method.toLowerCase() === "post") {
        parseFile(req, res)
    }
})
function parseFile(req, res) {
 req.setEncoding("binary");
 let body = "";
 let fileName = "";
 // 边界字符
 let boundary = req.headers['content-type']
                   .split('; ')[1]
                   .replace("boundary=", "")
 
 req.on("data", function(chunk) {
    body += chunk;
 });
 req.on("end", function() {
     // 按照分解符切分
     const list = body.split(boundary);
     let contentType = '';
     let fileName = '';
     for (let i = 0; i < list.length; i++) {
        if (list[i].includes('Content-Disposition')) {
            const data = list[i].split('rn');
            for (let j = 0; j < data.length; j++) {
                // 从头部拆分出名字和类型
                if (data[j].includes('Content-Disposition')) {
                    const info = data[j].split(':')[1].split(';');
                    fileName = info[info.length - 1].split('=')[1].replace(/"/g, '');
                    console.log(fileName);
                }
                if (data[j].includes('Content-Type')) {
                    contentType = data[j];
                    console.log(data[j].split(':')[1]);
                }
            }
        }
    }
    // 去除前面的请求头
    const start = body.toString().indexOf(contentType) + contentType.length + 4; // 有多rnrn
    const startBinary = body.toString().substring(start);
    const end = startBinary.indexOf("--" + boundary + "--") - 2; // 前面有多rn
    // 去除后面的分隔符
    const binary = startBinary.substring(0, end);
    const bufferData = Buffer.from(binary, "binary");
    fs.writeFile(fileName, bufferData, function(err) {
        res.end("sucess");
    });
 });
}
server.listen(7787)

总结

相信有了以上的介绍,你不再对文件上传有所惧怕, 对文件上传整个过程都会比较清晰了,还不懂。。。。找我。

再次回顾下我们的重点:

请求端出问题,浏览器端打开 network 查看格式是否正确(请求头,请求体), 如果数据不够详细,打开wireshark,对照我们的规范标准,看下格式(请求头,请求体)。

接收端出问题,情况一就是请求端缺少信息,参考上面请求端出问题的情况,情况二请求体内容错误,如果说请求体内容是请求端自己构造的,那么需要检查请求体是否是正确的二进制流(例如上面的blob构造的时候,我一开始少了一个[],导致内容主体错误)。

其实讲这么多就两个字:  规范,所有的生态都是围绕它而展开的。

查看原文

赞 0 收藏 0 评论 0

万年打野易大师 发布了文章 · 2020-12-08

大文件上传优化,断点续传,分片上传

整体思路

第一步是结合项目背景,调研比较优化的解决方案。文件上传失败是老生常谈的问题,常用方案是将一个大文件切片成多个小文件,并行请求接口进行上传,所有请求得到响应后,在服务器端合并所有的分片文件。当分片上传失败,可以在重新上传时进行判断,只上传上次失败的部分,减少用户的等待时间,缓解服务器压力。这就是分片上传文件。

大文件上传

那么如何实现大文件分片上传呢?

流程图如下:

大文件上传流程图

分为以下步骤实现:

1. 文件 MD5 加密

MD5 是文件的唯一标识,可以利用文件的 MD5 查询文件的上传状态。

根据文件的修改时间、文件名称、最后修改时间等信息,通过 spark-md5[2] 生成文件的 MD5。需要注意的是,大规格文件需要分片读取文件,将读取的文件内容添加到 spark-md5[3] 的 hash 计算中,直到文件读取完毕,最后返回最终的 hash 码到 callback 回调函数里面。这里可以根据需要添加文件读取的进度条。

MD5 加密过程

实现方法如下:

// 修改时间+文件名称+最后修改时间-->MD5
md5File (file) {
  return new Promise((resolve, reject) => {
    let blobSlice =
      File.prototype.slice ||
      File.prototype.mozSlice ||
      File.prototype.webkitSlice
    let chunkSize = file.size / 100
    let chunks = 100
    let currentChunk = 0
    let spark = new SparkMD5.ArrayBuffer()
    let fileReader = new FileReader()
    fileReader.onload = function (e) {
      console.log('read chunk nr', currentChunk + 1, 'of', chunks)
      spark.append(e.target.result) // Append array buffer
      currentChunk++
      if (currentChunk < chunks) {
        loadNext()
      } else {
        let cur = +new Date()
        console.log('finished loading')
        // alert(spark.end() + '---' + (cur - pre)); // Compute hash
        let result = spark.end()
        resolve(result)
      }
    }
    fileReader.onerror = function (err) {
      console.warn('oops, something went wrong.')
      reject(err)
    }
    function loadNext () {
      let start = currentChunk * chunkSize
      let end =
        start + chunkSize >= file.size ? file.size : start + chunkSize
      fileReader.readAsArrayBuffer(blobSlice.call(file, start, end))
    }
    loadNext()
  })
}

2. 查询文件状态

前端得到文件的 MD5 后,从后台查询是否存在名称为 MD5 的文件夹,如果存在,列出文件夹下所有文件,得到已上传的切片列表,如果不存在,则已上传的切片列表为空。

// 校验文件的MD5
checkFileMD5 (file, fileName, fileMd5Value, onError) {
  const fileSize = file.size
  const { chunkSize, uploadProgress } = this
  this.chunks = Math.ceil(fileSize / chunkSize)
  return new Promise(async (resolve, reject) => {
    const params = {
      fileName: fileName,
      fileMd5Value: fileMd5Value,
    }
    const { ok, data } = await services.checkFile(params)
    if (ok) {
      this.hasUploaded = data.chunkList.length
      uploadProgress(file)
      resolve(data)
    } else {
      reject(ok)
      onError()
    }
  })
}

3. 文件分片

文件上传优化的核心就是文件分片,Blob 对象中的 slice 方法可以对文件进行切割,File 对象是继承 Blob 对象的,因此 File 对象也有 slice 方法。

定义每一个分片文件的大小变量为 chunkSize,通过文件大小 FileSize 和分片大小 chunkSize 得到分片数量 chunks,使用 for 循环和 file.slice() 方法对文件进行分片,序号为 0 - n,和已上传的切片列表做比对,得到所有未上传的分片,push 到请求列表 requestList。

文件分片

async checkAndUploadChunk (file, fileMd5Value, chunkList) {
  let { chunks, upload } = this
  const requestList = []
  for (let i = 0; i < chunks; i++) {
    let exit = chunkList.indexOf(i + '') > -1
    // 如果已经存在, 则不用再上传当前块
    if (!exit) {
      requestList.push(upload(i, fileMd5Value, file))
    }
  }
  console.log({ requestList })
  const result =
    requestList.length > 0
      ? await Promise.all(requestList)
        .then(result => {
          console.log({ result })
          return result.every(i => i.ok)
        })
        .catch(err => {
          return err
        })
      : true
  console.log({ result })
  return result === true
}

4. 上传分片

调用 Promise.all 并发上传所有的切片,将切片序号、切片文件、文件 MD5 传给后台。

后台接收到上传请求后,首先查看名称为文件 MD5 的文件夹是否存在,不存在则创建文件夹,然后通过 fs-extra 的 rename 方法,将切片从临时路径移动切片文件夹中,结果如下:

上传分片

当全部分片上传成功,通知服务端进行合并,当有一个分片上传失败时,提示“上传失败”。在重新上传时,通过文件 MD5 得到文件的上传状态,当服务器已经有该 MD5 对应的切片时,代表该切片已经上传过,无需再次上传,当服务器找不到该 MD5 对应的切片时,代表该切片需要上传,用户只需上传这部分切片,就可以完整上传整个文件,这就是文件的断点续传。

断点续传示意图

// 上传chunk
upload (i, fileMd5Value, file) {
  const { uploadProgress, chunks } = this
  return new Promise((resolve, reject) => {
    let { chunkSize } = this
    // 构造一个表单,FormData是HTML5新增的
    let end =
      (i + 1) * chunkSize >= file.size ? file.size : (i + 1) * chunkSize
    let form = new FormData()
    form.append('data', file.slice(i * chunkSize, end)) // file对象的slice方法用于切出文件的一部分
    form.append('total', chunks) // 总片数
    form.append('index', i) // 当前是第几片
    form.append('fileMd5Value', fileMd5Value)
    services
      .uploadLarge(form)
      .then(data => {
        if (data.ok) {
          this.hasUploaded++
          uploadProgress(file)
        }
        console.log({ data })
        resolve(data)
      })
      .catch(err => {
        reject(err)
      })
  })
}

5. 上传进度

虽然分片批量上传比大文件单次上传会快很多,也还是有一段加载时间,这时应该加上上传进度的提示,实时显示文件上传进度。

原生 Javascript 的 XMLHttpRequest 有提供 progress 事件,这个事件会返回文件已上传的大小和总大小。项目使用 axios[4] 对 ajax 进行封装,可以在 config 中增加 onUploadProgress 方法,监听文件上传进度。

上传进度

const config = {
  onUploadProgress: progressEvent => {
    var complete = (progressEvent.loaded / progressEvent.total * 100 | 0) + '%'
  }
}
services.uploadChunk(form, config)

6. 合并分片

上传完所有文件分片后,前端主动通知服务端进行合并,服务端接受到这个请求时主动合并切片,通过文件 MD5 在服务器的文件上传路径中找到同名文件夹。从上文可知,文件分片是按照分片序号命名的,而分片上传接口是异步的,无法保证服务器接收到的切片是按照请求顺序拼接。所以应该在合并文件夹里的分片文件前,根据文件名进行排序,然后再通过 concat-files 合并分片文件,得到用户上传的文件。至此大文件上传就完成了。

merge

合并分片示意图

Node 端代码:

// 合并文件
exports.merge = {
  validate: {
    query: {
      fileName: Joi.string()
        .trim()
        .required()
        .description('文件名称'),
      md5: Joi.string()
        .trim()
        .required()
        .description('文件md5'),
      size: Joi.string()
        .trim()
        .required()
        .description('文件大小'),
    },
  },
  permission: {
    roles: ['user'],
  },
  async handler (ctx) {
    const { fileName, md5, size } = ctx.request.query
    let { name, base: filename, ext } = path.parse(fileName)
    const newFileName = randomFilename(name, ext)
    await mergeFiles(path.join(uploadDir, md5), uploadDir, newFileName, size)
      .then(async () => {
        const file = {
          key: newFileName,
          name: filename,
          mime_type: mime.getType(`${uploadDir}/${newFileName}`),
          ext,
          path: `${uploadDir}/${newFileName}`,
          provider: 'oss',
          size,
          owner: ctx.state.user.id,
        }
        const key = encodeURIComponent(file.key)
          .replace(/%/g, '')
          .slice(-100)
        file.url = await uploadLocalFileToOss(file.path, key)
        file.url = getFileUrl(file)
        const f = await File.create(omit(file, 'path'))
        const files = []
        files.push(f)
        ctx.body = invokeMap(files, 'toJSON')
      })
      .catch(() => {
        throw Boom.badData('大文件分片合并失败,请稍候重试~')
      })
  },
}

总结

本文讲述了大规格文件上传优化的一些做法,总结为以下 4 点:

  1. Blob.slice 将文件切片,并发上传多个切片,所有切片上传后告知服务器合并,实现大文件分片上传;
  2. 原生 XMLHttpRequest 的 onprogress 对切片上传进度的监听,实时获取文件上传进度;
  3. spark-md5 根据文件内容算出文件 MD5,得到文件唯一标识,与文件上传状态绑定;
  4. 分片上传前通过文件 MD5 查询已上传切片列表,上传时只上传未上传过的切片,实现断点续传。
查看原文

赞 3 收藏 0 评论 0

万年打野易大师 发布了文章 · 2020-12-08

图片处理好用的工具库

下面列举的图片处理库拥有模糊、压缩、裁剪、旋转、合成、比对等技能。能满足我们基本使用图片的操作。

你将学习到:

  • 如何区分图片的类型(非文件后缀名);
  • 如何获取图片的尺寸(非右键查看图片信息);
  • 如何预览本地图片(非图片阅读器);
  • 如何实现图片压缩(非图片压缩工具);
  • 如何操作位图像素数据(非 PS 等图片处理软件);
  • 如何实现图片隐写(非肉眼可见)。

一、基础知识

1.1 位图

「位图图像(bitmap),亦称为点阵图像或栅格图像,是由称作像素(图片元素)的单个点组成的。」 这些点可以进行不同的排列和染色以构成图样。当放大位图时,可以看见赖以构成整个图像的无数单个方块。扩大位图尺寸的效果是增大单个像素,从而使线条和形状显得参差不齐。

「用数码相机拍摄的照片、扫描仪扫描的图片以及计算机截屏图等都属于位图。」 位图的特点是可以表现色彩的变化和颜色的细微过渡,产生逼真的效果,缺点是在保存时需要记录每一个像素的位置和颜色值,占用较大的存储空间。常用的位图处理软件有 Photoshop、Painter 和 Windows 系统自带的画图工具等。

分辨率是位图不可逾越的壁垒,在对位图进行缩放、旋转等操作时,无法生产新的像素,因此会放大原有的像素填补空白,这样会让图片显得不清晰。

(图片来源:https://zh.wikipedia.org/wiki...

图中的小方块被称为像素,这些小方块都有一个明确的位置和被分配的色彩数值,小方格颜色和位置就决定该图像所呈现出来的样子。

可以将像素视为整个图像中不可分割的单位或者是元素。「不可分割的意思是它不能够再切割成更小单位抑或是元素,它是以一个单一颜色的小格存在。」 每一个点阵图像包含了一定量的像素,这些像素决定图像在屏幕上所呈现的大小。

1.2 矢量图

所谓矢量图,就是使用直线和曲线来描述的图形,构成这些图形的元素是一些点、线、矩形、多边形、圆和弧线等,*「它们都是通过数学公式计算获得的,具有编辑后不失真的特点。*例如一幅画的矢量图形实际上是由线段形成外框轮廓,由外框的颜色以及外框所封闭的颜色决定画显示出的颜色。

「矢量图以几何图形居多,图形可以无限放大,不变色、不模糊。」 常用于图案、标志、VI、文字等设计。常用软件有:CorelDraw、Illustrator、Freehand、XARA、CAD 等。

这里我们以 Web 开发者比较熟悉的 SVG(「Scalable Vector Graphics —— 可缩放矢量图形」)为例,来了解一下 SVG 的结构:

可缩放矢量图形(英语:Scalable Vector Graphics,SVG)是一种基于可扩展标记语言(XML),用于描述二维矢量图形的图形格式。SVG 由 W3C 制定,是一个开放标准。

SVG 主要支持以下几种显示对象:

  • 矢量显示对象,基本矢量显示对象包括矩形、圆、椭圆、多边形、直线、任意曲线等;
  • 嵌入式外部图像,包括 PNG、JPEG、SVG 等;
  • 文字对象。

了解完位图与矢量图的区别,下面我们来介绍一下位图的数学表示。

1.3 位图的数学表示

位图的像素都分配有特定的位置和颜色值。每个像素的颜色信息由 RGB 组合或者灰度值表示。

根据位深度,可将位图分为1、4、8、16、24 及 32 位图像等。每个像素使用的信息位数越多,可用的颜色就越多,颜色表现就越逼真,相应的数据量越大。

「1.3.1 二值图像」

位深度为 1 的像素位图只有两个可能的值(黑色和白色),所以又称为二值图像。二值图像的像素点只有黑白两种情况,因此每个像素点可以由 0 和 1 来表示。

比如一张 4 * 4 二值图像:

1 1 0 1
1 1 0 1
1 0 0 0
1 0 1 0

「1.3.2 RGB 图像」

RGB 图像由三个颜色通道组成,其中 RGB 代表红、绿、蓝三个通道的颜色。8 位/通道的 RGB 图像中的每个通道有 256 个可能的值,这意味着该图像有 1600 万个以上可能的颜色值。

有时将带有 8 位/通道(bpc)的 RGB 图像称作 24 位图像(8 位 x 3 通道 = 24 位数据/像素)。通常将使用 24 位 RGB 组合数据位表示的的位图称为真彩色位图。

RGB 彩色图像可由三种矩阵表示:一种代表像素中红色的强度,一种代表绿色,另一种代表蓝色。

(图片来源:https://freecontent.manning.c...

「图像处理的本质实际上就是对这些像素矩阵进行计算。」 其实位图中的图像类型,除了二值图像和 RGB 图像之外,还有灰度图像、索引图像和 YUV 图像。这里我们不做过多介绍,感兴趣的小伙伴,请自行查阅相关资料。

二、图片处理库

2.1 AlloyImage

基于 HTML 5 的专业级图像处理开源引擎。

https://github.com/AlloyTeam/...

AlloyImage 基于 HTML5 技术的专业图像处理库,来自腾讯 AlloyTeam 团队。它拥有以下功能特性:

  • 基于多图层操作 —— 一个图层的处理不影响其他图层;
  • 与 PS 对应的 17 种图层混合模式 —— 便于 PS 处理教程的无缝迁移;
  • 多种基本滤镜处理效果 —— 基本滤镜不断丰富、可扩展;
  • 基本的图像调节功能 —— 色相、饱和度、对比度、亮度、曲线等;
  • 简单快捷的 API —— 链式处理、API 简洁易用、传参灵活;
  • 多种组合效果封装 —— 一句代码轻松实现一种风格;
  • 接口一致的单、多线程支持 —— 单、多线程切换无需更改一行代码,多线程保持快捷 API 特性。

对于该库 AlloyTeam 团队建议的使用场景如下:

  • 桌面软件客户端内嵌网页运行方式 >>> 打包 Webkit 内核:用户较大头像上传风格处理、用户相册风格处理(处理时间平均 < 1s);
  • Win8 Metro 应用 >>> 用户上传头像,比较小的图片风格处理后上传(Win8 下 IE 10 支持多线程);
  • Mobile APP >>> Andriod 平台、iOS 平台小图风格 Web 处理的需求,如 PhoneGap 应用,在线头像上传时的风格处理、Mobile Web 端分享图片时风格处理等。

「使用示例」

// $AI或AlloyImage初始化一个AlloyImage对象
var ps = $AI(img, 600).save('jpg', 0.6);
// save将合成图片保存成base64格式字符串
var string = AlloyImage(img).save('jpg', 0.8);
// saveFile将合成图片下载到本地
img.onclick = function(){
  AlloyImage(this).saveFile('处理后图像.jpg', 0.8);
}

「在线示例」

http://alloyteam.github.io/Al...

(图片来源:http://alloyteam.github.io/Al...

2.2 blurify

blurify.js is a tiny(~2kb) library to blurred pictures, support graceful downgrade from css mode to canvas mode.

https://github.com/JustClear/...

blurify.js 是一个用于图片模糊,很小的 JavaScript 库(约 2 kb),并支持从 CSS 模式到 Canvas 模式的优雅降级。该插件支持三种模式:

  • css 模式:使用 filter 属性,默认模式;
  • canvas 模式:使用 canvas 导出 base64;
  • auto 模式:优先使用 css 模式,否则自动切换到 canvas 模式。

「使用示例」

import blurify from 'blurify';
new blurify({
    images: document.querySelectorAll('.blurify'),
    blur: 6,
    mode: 'css',
});
// or in shorthand
blurify(6, document.querySelectorAll('.blurify'));

「在线示例」

https://justclear.github.io/b...

(图片来源:https://justclear.github.io/b...

看到这里是不是有些小伙伴觉得只是模糊处理而已,觉得不过瘾,能不能来点更酷的。嘿嘿,有求必应!阿宝哥立马来个 「“酷炫叼”」 的库 —— midori,该库用于为背景图创建动画,使用 three.js 编写并使用 WebGL。本来是想给个演示动图,无奈单个 Gif 文件太大,只能放个体验地址,感兴趣的小伙伴自行体验一下。

midori 示例地址:https://aeroheim.github.io/mi...

2.3 cropperjs

JavaScript image cropper.

https://github.com/fengyuanch...

Cropper.js 是一款非常强大却又简单的图片裁剪工具,它可以进行非常灵活的配置,支持手机端使用,支持包括 IE9 以上的现代浏览器。它可以用于满足诸如裁剪头像上传、商品图片编辑之类的需求。

Cropper.js 支持以下特性:

  • 支持 39 个配置选项;
  • 支持 27 个方法;
  • 支持 6 种事件;
  • 支持 touch(移动端);
  • 支持缩放、旋转和翻转;
  • 支持在画布上裁剪;
  • 支持在浏览器端通过画布裁剪图像;
  • 支持处理 Exif 方向信息;
  • 跨浏览器支持。
可交换图像文件格式(英语:Exchangeable image file format,官方简称 Exif),是专门为数码相机的照片设定的文件格式,可以记录数码照片的属性信息和拍摄数据。Exif 可以附加于 JPEG、TIFF、RIFF 等文件之中,为其增加有关数码相机拍摄信息的内容和索引图或图像处理软件的版本信息。

Exif 信息以 0xFFE1 作为开头标记,后两个字节表示 Exif 信息的长度。所以 Exif 信息最大为 64 kB,而内部采用 TIFF 格式。

「使用示例」

// import 'cropperjs/dist/cropper.css';
import Cropper from 'cropperjs';
const image = document.getElementById('image');
const cropper = new Cropper(image, {
  aspectRatio: 16 / 9,
  crop(event) {
    console.log(event.detail.x);
    console.log(event.detail.y);
    console.log(event.detail.width);
    console.log(event.detail.height);
    console.log(event.detail.rotate);
    console.log(event.detail.scaleX);
    console.log(event.detail.scaleY);
  },
});

「在线示例」

https://fengyuanchen.github.i...

2.4 compressorjs

JavaScript image compressor.

https://github.com/fengyuanch...

compressorjs 是 JavaScript 图像压缩器。使用浏览器原生的 canvas.toBlob API 进行压缩工作,这意味着它是有损压缩。通常的使用场景是,在浏览器端图片上传之前对其进行预压缩。

在浏览器端要实现图片压缩,除了使用 canvas.toBlob API 之外,还可以使用 Canvas 提供的另一个 API,即 toDataURL API,它接收 typeencoderOptions 两个可选参数。

其中 type 表示图片格式,默认为 image/png。而 encoderOptions 用于表示图片的质量,在指定图片格式为 image/jpegimage/webp 的情况下,可以从 0 到 1 的区间内选择图片的质量。如果超出取值范围,将会使用默认值 0.92,其他参数会被忽略。

相比 canvas.toDataURL API 来说,canvas.toBlob API 是异步的,因此多了个callback 参数,这个 callback 回调方法默认的第一个参数就是转换好的 blob 文件信息。canvas.toBlob 的签名如下:

canvas.toBlob(callback, mimeType, qualityArgument)

「使用示例」

import axios from 'axios';
import Compressor from 'compressorjs';
// <input type="file" id="file" accept="image/*">
document.getElementById('file').addEventListener('change', (e) => {
  const file = e.target.files[0];
  if (!file) {
    return;
  }
  new Compressor(file, {
    quality: 0.6,
    success(result) {
      const formData = new FormData();
      // The third parameter is required for server
      formData.append('file', result, result.name);
      // Send the compressed image file to server with XMLHttpRequest.
      axios.post('/path/to/upload', formData).then(() => {
        console.log('Upload success');
      });
    },
    error(err) {
      console.log(err.message);
    },
  });
});

「在线示例」

https://fengyuanchen.github.i...

2.5 fabric.js

Javascript Canvas Library, SVG-to-Canvas (& canvas-to-SVG) Parser.

https://github.com/fabricjs/f...

Fabric.js 是一个框架,可让你轻松使用 HTML5 Canvas 元素。它是一个位于 Canvas 元素之上的交互式对象模型,同时也是一个 「SVG-to-canvas」 的解析器。

使用 Fabric.js,你可以在画布上创建和填充对象。所谓的对象,可以是简单的几何形状,比如矩形,圆形,椭圆形,多边形,或更复杂的形状,包含数百或数千个简单路径。然后,你可以使用鼠标缩放,移动和旋转这些对象。并修改它们的属性 —— 颜色,透明度,z-index 等。此外你还可以一起操纵这些对象,即通过简单的鼠标选择将它们分组。

Fabric.js 支持所有主流的浏览器,具体的兼容情况如下:

  • Firefox 2+
  • Safari 3+
  • Opera 9.64+
  • Chrome(所有版本)
  • IE10,IE11,Edge

「使用示例」

<!DOCTYPE html>
<html>
<head></head>
<body>
    <canvas id="canvas" width="300" height="300"></canvas>
    <script data-original="lib/fabric.js"></script>
    <script> var canvas = new fabric.Canvas('canvas');
        var rect = new fabric.Rect({
            top : 100,
            left : 100,
            width : 60,
            height : 70,
            fill : 'red'
        });
        canvas.add(rect); </script>
</body>
</html>

「在线示例」

http://fabricjs.com/kitchensink

(图片来源:https://github.com/fabricjs/f...

2.6 Resemble.js

Image analysis and comparison

https://github.com/rsmbl/Rese...

Resemble.js 使用 HTML Canvas 和 JavaScript 来实现图片的分析和比较。兼容大于 8.0 的 Node.js 版本。

「使用示例」

// 比较两张图片
var diff = resemble(file)
    .compareTo(file2)
    .ignoreColors()
    .onComplete(function(data) {
        console.log(data);
     /*
     {
        misMatchPercentage : 100, // %
        isSameDimensions: true, // or false
        dimensionDifference: { width: 0, height: -1 }, 
        getImageDataUrl: function(){}
     }
    */
});

「在线示例」

http://rsmbl.github.io/Resemb...

2.7 Pica

Resize image in browser with high quality and high speed

https://github.com/nodeca/pica

Pica 可用于在浏览器中调整图像大小,没有像素化并且相当快。它会自动选择最佳的可用技术:webworkers,webassembly,createImageBitmap,纯 JS。

借助 Pica,你可以实现以下功能:

  • 减小大图像的上传大小,节省上传时间;
  • 在图像处理上节省服务器资源;
  • 在浏览器中生成缩略图。

「使用示例」

const pica = require('pica')();
// 调整画布/图片的大小
pica.resize(from, to, {
  unsharpAmount: 80,
  unsharpRadius: 0.6,
  unsharpThreshold: 2
})
.then(result => console.log('resize done!'));
// 调整大小并转换为Blob
pica.resize(from, to)
  .then(result => pica.toBlob(result, 'image/jpeg', 0.90))
  .then(blob => console.log('resized to canvas & created blob!'));

「在线示例」

http://nodeca.github.io/pica/...

2.8 tui.image-editor

🍞🎨 Full-featured photo image editor using canvas. It is really easy, and it comes with great filters.

https://github.com/nhn/tui.im...

tui.image-editor 是使用 HTML5 Canvas 的全功能图像编辑器。它易于使用,并提供强大的过滤器。同时它支持对图像进行裁剪、翻转、旋转、绘图、形状、文本、遮罩和图片过滤等操作。

tui.image-editor 的浏览器兼容情况如下:

  • Chrome
  • Edge
  • Safari
  • Firefox
  • IE 10+

「使用示例」

// Image editor
var imageEditor = new tui.ImageEditor("#tui-image-editor-container", {
     includeUI: {
       loadImage: {
         path: "img/sampleImage2.png",
         name: "SampleImage",
       },
       theme: blackTheme, // or whiteTheme
         initMenu: "filter",
         menuBarPosition: "bottom",
       },
       cssMaxWidth: 700,
       cssMaxHeight: 500,
       usageStatistics: false,
});
window.onresize = function () {
  imageEditor.ui.resizeEditor();
};

在线示例

https://ui.toast.com/tui-imag...

2.9 gif.js

JavaScript GIF encoding library

https://github.com/jnordberg/...

gif.js 是运行在浏览器端的 JavaScript GIF 编码器。它使用类型化数组和 Web Worker 在后台渲染每一帧,速度真的很快。该库可工作在支持:Web Workers,File API 和 Typed Arrays 的浏览器中。

gif.js 的浏览器兼容情况如下:

  • Google Chrome
  • Firefox 17
  • Safari 6
  • Internet Explorer 10
  • Mobile Safari iOS 6

「使用示例」

var gif = new GIF({
  workers: 2,
  quality: 10
});
// add an image element
gif.addFrame(imageElement);
// or a canvas element
gif.addFrame(canvasElement, {delay: 200});
// or copy the pixels from a canvas context
gif.addFrame(ctx, {copy: true});
gif.on('finished', function(blob) {
  window.open(URL.createObjectURL(blob));
});
gif.render();

「在线示例」

http://jnordberg.github.io/gi...

2.10 Sharp

High performance Node.js image processing, the fastest module to resize JPEG, PNG, WebP and TIFF images. Uses the libvips library.

https://github.com/lovell/sharp

Sharp 的典型应用场景是将常见格式的大图像转换为尺寸较小,对网络友好的 JPEG,PNG 和 WebP 格式的图像。由于其内部使用 libvips ,使得调整图像大小通常比使用 ImageMagick 和 GraphicsMagick 设置快 4-5 倍 。除了支持调整图像大小之外,Sharp 还支持旋转、提取、合成和伽马校正等功能。

Sharp 支持读取 JPEG,PNG,WebP,TIFF,GIF 和 SVG 图像。输出图像可以是 JPEG,PNG,WebP 和 TIFF 格式,也可以是未压缩的原始像素数据。

「使用示例」

// 改变图像尺寸
sharp(inputBuffer)
  .resize(320, 240)
  .toFile('output.webp', (err, info) => { ... });
       
// 旋转输入图像并改变图片尺寸 
sharp('input.jpg')
  .rotate()
  .resize(200)
  .toBuffer()
  .then( data => { ... })
  .catch( err => { ... }); 

「在线示例」

https://segmentfault.com/a/11...

该示例是来自阿宝哥 18 年写的 “Sharp 牛刀小试之生成专属分享图片” 这篇文章,主要是利用 Sharp 提供的图片合成功能为每个用户生成专属的分享海报,感兴趣的小伙伴可以阅读一下原文哟。

const sharp = require("sharp");
const TextToSVG = require("text-to-svg");
const path = require("path");
// 加载字体文件
const textToSVG = TextToSVG.loadSync(path.join(__dirname, "./simhei.ttf"));
// 创建圆形SVG,用于实现头像裁剪
const roundedCorners = new Buffer(
  '<svg><circle r="90" cx="90" cy="90"/></svg>'
);
// 设置SVG文本元素相关参数
const attributes = { fill: "white" };
const svgOptions = {
  x: 0,
  y: 0,
  fontSize: 32,
  anchor: "top",
  attributes: attributes
};
/**
 * 使用文本生成SVG
 * @param {*} text 
 * @param {*} options 
 */
function textToSVGFn(text, options = svgOptions) {
  return textToSVG.getSVG(text, options);
}
/**
 * 图层叠加生成分享图片
 * @param {*} options 
 * 
 */
async function genShareImage(options) {
  const { backgroudPath, avatarPath, qrcodePath, 
    userName, words, likes, outFilePath
  } = options;
  // 背景图片
  const backgroudBuffer = sharp(path.join(__dirname, backgroudPath)).toBuffer({
    resolveWithObject: true
  });
  const backgroundImageInfo = await backgroudBuffer;
  // 头像图片
  const avatarBuffer = await genCircleAvatar(path.join(__dirname, avatarPath));
  // 二维码图片
  const qrCodeBuffer = await sharp(path.join(__dirname, qrcodePath))
    .resize(180)
    .toBuffer({
      resolveWithObject: true
    });
  // 用户名
  const userNameSVG = textToSVGFn(userName);
  // 用户数据
  const userDataSVG = textToSVGFn(`写了${words}个字   收获${likes}个赞`);
  const userNameBuffer = await sharp(new Buffer(userNameSVG)).toBuffer({
    resolveWithObject: true
  });
  const userDataBuffer = await sharp(new Buffer(userDataSVG)).toBuffer({
    resolveWithObject: true
  });
  const buffers = [avatarBuffer, qrCodeBuffer, userNameBuffer, userDataBuffer];
  // 图层叠加参数列表
  const overlayOptions = [
    { top: 150, left: 230 },
    { top: 861, left: 227 },
    {
      top: 365,
      left: (backgroundImageInfo.info.width - userNameBuffer.info.width) / 2
    },
    {
      top: 435,
      left: (backgroundImageInfo.info.width - userDataBuffer.info.width) / 2
    }
  ];
  // 组合多个图层:图片+文字图层
  return buffers
    .reduce((input, overlay, index) => {
      return input.then(result => {
        console.dir(overlay.info);
        return sharp(result.data)
          .overlayWith(overlay.data, overlayOptions[index])
          .toBuffer({ resolveWithObject: true });
      });
    }, backgroudBuffer)
    .then((data) => {
      return sharp(data.data).toFile(outFilePath);
    }).catch(error => {
      throw new Error('Generate Share Image Failed.');
    });
}
/**
 * 生成圆形的头像
 * @param {*} avatarPath 头像路径
 */
function genCircleAvatar(avatarPath) {
  return sharp(avatarPath)
    .resize(180, 180)
    .overlayWith(roundedCorners, { cutout: true })
    .png()
    .toBuffer({
      resolveWithObject: true
    });
}
module.exports = {
  genShareImage
};

三、实用示例

3.1 如何区分图片的类型

「计算机并不是通过图片的后缀名来区分不同的图片类型,而是通过 “魔数”(Magic Number)来区分。」 对于某一些类型的文件,起始的几个字节内容都是固定的,跟据这几个字节的内容就可以判断文件的类型。

常见图片类型对应的魔数如下表所示:

文件类型文件后缀魔数
JPEGjpg/jpeg0xFFD8FF
PNGpng0x89504E47
GIFgif0x47494638(GIF8)
BMPbmp0x424D

这里我们以阿宝哥的头像(abao.png)为例,验证一下该图片的类型是否正确:

在日常开发过程中,如果遇到检测图片类型的场景,我们可以直接利用一些现成的第三方库。比如,你想要判断一张图片是否为 PNG 类型,这时你可以使用 is-png 这个库,它同时支持浏览器和 Node.js,使用示例如下:

「Node.js」

// npm install read-chunk
const readChunk = require('read-chunk'); 
const isPng = require('is-png');
const buffer = readChunk.sync('unicorn.png', 0, 8);
isPng(buffer);
//=> true

「Browser」

(async () => {
 const response = await fetch('unicorn.png');
 const buffer = await response.arrayBuffer();
 isPng(new Uint8Array(buffer));
 //=> true
})();

3.2 如何获取图片的尺寸

图片的尺寸、位深度、色彩类型和压缩算法都会存储在文件的二进制数据中,我们继续以阿宝哥的头像(abao.png)为例,来了解一下实际的情况:

528(十进制) => 0x0210

560(十进制)=> 0x0230

因此如果想要获取图片的尺寸,我们就需要依据不同的图片格式对图片二进制数据进行解析。幸运的是,我们不需要自己做这件事,image-size 这个 Node.js 库已经帮我们实现了获取主流图片类型文件尺寸的功能:

「同步方式」

var sizeOf = require('image-size');
var dimensions = sizeOf('images/abao.png');
console.log(dimensions.width, dimensions.height);

「异步方式」

var sizeOf = require('image-size');
sizeOf('images/abao.png', function (err, dimensions) {
  console.log(dimensions.width, dimensions.height);
});

image-size 这个库功能还是蛮强大的,除了支持 PNG 格式之外,还支持 BMP、GIF、ICO、JPEG、SVG 和 WebP 等格式。

3.3 如何预览本地图片

利用 HTML FileReader API,我们也可以方便的实现图片本地预览功能,具体代码如下:

<input type="file" accept="image/*" onchange="loadFile(event)">
<img id="output"/>
<script> const loadFile = function(event) {
    const reader = new FileReader();
    reader.onload = function(){
      const output = document.querySelector('output');
      output.src = reader.result;
    };
    reader.readAsDataURL(event.target.files[0]);
  };
</script>

在完成本地图片预览之后,可以直接把图片对应的 Data URLs 数据提交到服务器。针对这种情形,服务端需要做一些相关处理,才能正常保存上传的图片,这里以 Express 为例,具体处理代码如下:

const app = require('express')();
app.post('/upload', function(req, res){
    let imgData = req.body.imgData; // 获取POST请求中的base64图片数据
    let base64Data = imgData.replace(/^data:image/w+;base64,/, "");
    let dataBuffer = Buffer.from(base64Data, 'base64');
    fs.writeFile("image.png", dataBuffer, function(err) {
        if(err){
          res.send(err);
        }else{
          res.send("图片上传成功!");
        }
    });
});

3.4 如何实现图片压缩

在一些场合中,我们希望在上传本地图片时,先对图片进行一定的压缩,然后再提交到服务器,从而减少传输的数据量。在前端要实现图片压缩,我们可以利用 Canvas 对象提供的 toDataURL() 方法,该方法接收 typeencoderOptions 两个可选参数。

其中 type 表示图片格式,默认为 image/png。而 encoderOptions 用于表示图片的质量,在指定图片格式为 image/jpegimage/webp 的情况下,可以从 0 到 1 的区间内选择图片的质量。如果超出取值范围,将会使用默认值 0.92,其他参数会被忽略。

下面我们来看一下具体如何实现图片压缩:

function compress(base64, quality, mimeType) {
  let canvas = document.createElement("canvas");
  let img = document.createElement("img");
  img.crossOrigin = "anonymous";
  return new Promise((resolve, reject) => {
    img.src = base64;
    img.onload = () => {
      let targetWidth, targetHeight;
      if (img.width > MAX_WIDTH) {
        targetWidth = MAX_WIDTH;
        targetHeight = (img.height * MAX_WIDTH) / img.width;
      } else {
        targetWidth = img.width;
        targetHeight = img.height;
      }
      canvas.width = targetWidth;
      canvas.height = targetHeight;
      let ctx = canvas.getContext("2d");
      ctx.clearRect(0, 0, targetWidth, targetHeight); // 清除画布
      ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
      let imageData = canvas.toDataURL(mimeType, quality / 100);
      resolve(imageData);
    };
  });
}

对于返回的 Data URL 格式的图片数据,为了进一步减少传输的数据量,我们可以把它转换为 Blob 对象:

function dataUrlToBlob(base64, mimeType) {
  let bytes = window.atob(base64.split(",")[1]);
  let ab = new ArrayBuffer(bytes.length);
  let ia = new Uint8Array(ab);
  for (let i = 0; i < bytes.length; i++) {
    ia[i] = bytes.charCodeAt(i);
  }
  return new Blob([ab], { type: mimeType });
}

在转换完成后,我们就可以压缩后的图片对应的 Blob 对象封装在 FormData 对象中,然后再通过 AJAX 提交到服务器上:

function uploadFile(url, blob) {
  let formData = new FormData();
  let request = new XMLHttpRequest();
  formData.append("image", blob);
  request.open("POST", url, true);
  request.send(formData);
}

3.5 如何操作位图像素数据

如果想要操作图片像素数据,我们可以利用 CanvasRenderingContext2D 提供的 getImageData 来获取图片像素数据,其中 getImageData() 返回一个 ImageData 对象,用来描述 canvas 区域隐含的像素数据,这个区域通过矩形表示,起始点为(sx, sy)、宽为 sw、高为 sh。其中 getImageData 方法的语法如下:

ctx.getImageData(sx, sy, sw, sh);

相应的参数说明如下:

  • sx:将要被提取的图像数据矩形区域的左上角 x 坐标。
  • sy:将要被提取的图像数据矩形区域的左上角 y 坐标。
  • sw:将要被提取的图像数据矩形区域的宽度。
  • sh:将要被提取的图像数据矩形区域的高度。

在获取到图片的像素数据之后,我们就可以对获取的像素数据进行处理,比如进行灰度化或反色处理。当完成处理后,若要在页面上显示处理效果,则我们需要利用 CanvasRenderingContext2D 提供的另一个 API —— putImageData

该 API 是 Canvas 2D API 将数据从已有的 ImageData 对象绘制到位图的方法。如果提供了一个绘制过的矩形,则只绘制该矩形的像素。此方法不受画布转换矩阵的影响。putImageData 方法的语法如下:

void ctx.putImageData(imagedata, dx, dy);
void ctx.putImageData(imagedata, dx, dy, dirtyX, dirtyY, dirtyWidth, dirtyHeight);

相应的参数说明如下:

  • imageData: ImageData ,包含像素值的数组对象。
  • dx:源图像数据在目标画布中的位置偏移量(x 轴方向的偏移量)。
  • dy:源图像数据在目标画布中的位置偏移量(y 轴方向的偏移量)。
  • dirtyX(可选):在源图像数据中,矩形区域左上角的位置。默认是整个图像数据的左上角(x 坐标)。
  • dirtyY(可选):在源图像数据中,矩形区域左上角的位置。默认是整个图像数据的左上角(y 坐标)。
  • dirtyWidth(可选):在源图像数据中,矩形区域的宽度。默认是图像数据的宽度。
  • dirtyHeight(可选):在源图像数据中,矩形区域的高度。默认是图像数据的高度。

介绍完相关的 API,下面我们来举一个实际例子:

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>图片反色和灰度化处理</title>
  </head>
  <body onload="loadImage()">
    <div>
      <button id="invertbtn">反色</button>
      <button id="grayscalebtn">灰度化</button>
    </div>
    <canvas id="canvas" width="800" height="600"></canvas>
    <script> function loadImage() {
        var img = new Image();
        img.crossOrigin = "";
        img.onload = function () {
          draw(this);
        };
        // 这是阿宝哥的头像哟
        img.src = "https://avatars3.githubusercontent.com/u/4220799";
      }
      function draw(img) {
        var canvas = document.getElementById("canvas");
        var ctx = canvas.getContext("2d");
        ctx.drawImage(img, 0, 0);
        img.style.display = "none";
        var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
        var data = imageData.data;
        var invert = function () {
          for (var i = 0; i < data.length; i += 4) {
            data[i] = 255 - data[i]; // red
            data[i + 1] = 255 - data[i + 1]; // green
            data[i + 2] = 255 - data[i + 2]; // blue
          }
          ctx.putImageData(imageData, 0, 0);
        };
        var grayscale = function () {
          for (var i = 0; i < data.length; i += 4) {
            var avg = (data[i] + data[i + 1] + data[i + 2]) / 3;
            data[i] = avg; // red
            data[i + 1] = avg; // green
            data[i + 2] = avg; // blue
          }
          ctx.putImageData(imageData, 0, 0);
        };
        var invertbtn = document.getElementById("invertbtn");
        invertbtn.addEventListener("click", invert);
        var grayscalebtn = document.getElementById("grayscalebtn");
        grayscalebtn.addEventListener("click", grayscale);
      } </script>
  </body>
</html>

需要注意的在调用 getImageData 方法获取图片像素数据时,你可能会遇到跨域问题,比如:

Uncaught DOMException: Failed to execute 'getImageData' on 'CanvasRenderingContext2D': The canvas has been tainted by cross-origin data.

对于这个问题,你可以阅读 「张鑫旭」 大神 “解决canvas图片getImageData,toDataURL跨域问题” 这一篇文章。

3.6 如何实现图片隐写

「隐写术是一门关于信息隐藏的技巧与科学,所谓信息隐藏指的是不让除预期的接收者之外的任何人知晓信息的传递事件或者信息的内容。」 隐写术的英文叫做 Steganography,来源于特里特米乌斯的一本讲述密码学与隐写术的著作 Steganographia,该书书名源于希腊语,意为 “隐秘书写”。

下图是在线的图片隐写工具,将 「“全栈修仙之路”」 这 6 个字隐藏到原始的图片中,然后使用对应的解密工具,解密出隐藏信息的结果:

(在线图片隐写体验地址:https://c.p2hp.com/yinxietu/

目前有多种方案可以实现图片隐写,以下是几种常见的方案:

  • 附加式的图片隐写;
  • 基于文件结构的图片隐写;
  • 基于 LSB 原理的图片隐写;
  • 基于 DCT 域的 JPG 图片隐写;
  • 数字水印的隐写;
  • 图片容差的隐写。

篇幅有限,这里我们就不继续展开,分别介绍每种方案,感兴趣的小伙伴可以阅读 “隐写术之图片隐写(一)” 这篇文章。

查看原文

赞 0 收藏 0 评论 0

万年打野易大师 发布了文章 · 2020-12-07

promise,async/await实现原理

        // 三种状态
        const PENDING = "pending";
        const RESOLVED = "resolved";
        const REJECTED = "rejected";
        // promise 接收一个函数参数,该函数会立即执行
        function MyPromise(fn) {
            let _this = this;
            _this.currentState = PENDING;
            _this.value = undefined;
            // 用于保存 then 中的回调,只有当 promise
            // 状态为 pending 时才会缓存,并且每个实例至多缓存一个
            _this.resolvedCallbacks = [];
            _this.rejectedCallbacks = [];
            _this.resolve = function (value) {
                if (value instanceof MyPromise) {
                    // 如果 value 是个 Promise,递归执行
                    return value.then(_this.resolve, _this.reject);
                }
                setTimeout(() => {
                    // 异步执行,必须保证执行顺序
                    if (_this.currentState === PENDING) {
                        _this.currentState = RESOLVED;
                        _this.value = value;
                        _this.resolvedCallbacks.forEach((cb) => cb());
                    }
                });
            };
            _this.reject = function (reason) {
                setTimeout(() => {
                    // 异步执行,保证执行顺序
                    if (_this.currentState === PENDING) {
                        _this.currentState = REJECTED;
                        _this.value = reason;
                        _this.rejectedCallbacks.forEach((cb) => cb());
                    }
                });
            };
            // 用于解决以下问题
            // new Promise(() => throw Error('error))
            try {
                fn(_this.resolve, _this.reject);
            } catch (e) {
                _this.reject(e);
            }
        }
        MyPromise.prototype.then = function (onResolved, onRejected) {
            var self = this;
            // 规范 2.2.7,then 必须返回一个新的 promise
            var promise2;
            // 规范 2.2.onResolved 和 onRejected 都为可选参数
            // 如果类型不是函数需要忽略,同时也实现了透传
            // Promise.resolve(v).then().then((value) => console.log(value))
            onResolved =
                typeof onResolved === "function" ? onResolved : (v) => v;
            onRejected = typeof onRejected === "function" ? onRejected : (r) => {throw r};
            if (self.currentState === RESOLVED) {
                return (promise2 = new MyPromise(function (resolve, reject) {
                    // 规范 2.2.4,保证 onFulfilled,onRjected 异步执行
                    // 所以用了 setTimeout 包裹下
                    setTimeout(function () {
                        try {
                            var x = onResolved(self.value);
                            resolutionProcedure(promise2, x, resolve, reject);
                        } catch (reason) {
                            reject(reason);
                        }
                    });
                }));
            }
            if (self.currentState === REJECTED) {
                return (promise2 = new MyPromise(function (resolve, reject) {
                    setTimeout(function () {
                        // 异步执行onRejected
                        try {
                            var x = onRejected(self.value);
                            resolutionProcedure(promise2, x, resolve, reject);
                        } catch (reason) {
                            reject(reason);
                        }
                    });
                }));
            }
            if (self.currentState === PENDING) {
                return (promise2 = new MyPromise(function (resolve, reject) {
                    self.resolvedCallbacks.push(function () {
                        // 考虑到可能会有报错,所以使用 try/catch 包裹
                        try {
                            var x = onResolved(self.value);
                            resolutionProcedure(promise2, x, resolve, reject);
                        } catch (r) {
                            reject(r);
                        }
                    });
                    self.rejectedCallbacks.push(function () {
                        try {
                            var x = onRejected(self.value);
                            resolutionProcedure(promise2, x, resolve, reject);
                        } catch (r) {
                            reject(r);
                        }
                    });
                }));
            }
        };
        // 规范 2.3
        function resolutionProcedure(promise2, x, resolve, reject) {
            // 规范 2.3.1,x 不能和 promise2 相同,避免循环引用
            if (promise2 === x) {
                return reject(new TypeError("Error"));
            }
            // 规范 2.3.2
            // 如果 x 为 Promise,状态为 pending 需要继续等待否则执行
            if (x instanceof MyPromise) {
                if (x.currentState === PENDING) {
                    x.then(function (value) {
                        // 再次调用该函数是为了确认 x resolve 的
                        // 参数是什么类型,如果是基本类型就再次 resolve
                        // 把值传给下个 then
                        resolutionProcedure(promise2, value, resolve, reject);
                    }, reject);
                } else {
                    x.then(resolve, reject);
                }
                return;
            }
            // 规范 2.3.3.3.3
            // reject 或者 resolve 其中一个执行过得话,忽略其他的
            let called = false;
            // 规范 2.3.3,判断 x 是否为对象或者函数
            if (x !== null && (typeof x === "object" || typeof x === "function")) {
                // 规范 2.3.3.2,如果不能取出 then,就 reject
                try {
                    // 规范 2.3.3.1
                    let then = x.then;
                    // 如果 then 是函数,调用 x.then
                    if (typeof then === "function") {
                        // 规范 2.3.3.3
                        then.call(
                            x,
                            (y) => {
                                if (called) return;
                                called = true;
                                // 规范 2.3.3.3.1
                                resolutionProcedure(promise2, y, resolve, reject);
                            },
                            (e) => {
                                if (called) return;
                                called = true;
                                reject(e);
                            }
                        );
                    } else {
                        // 规范 2.3.3.4
                        resolve(x);
                    }
                } catch (e) {
                    if (called) return;
                    called = true;
                    reject(e);
                }
            } else {
                // 规范 2.3.4,x 为基本类型
                resolve(x);
            }
        }

async/await基于promise的语法糖:

async函数返回一个promise对象

const async = (func) => {
    const p = new Promise((resolve, reject) => {
        try {
            const value = func()
            if (
                ((typeof value === 'object' && value !== null) || typeof value === 'function') &&
                typeof value.then === 'function') {
                Promise.resolve(value).then(resolve, reject)
            } else {
                resolve(value)
            }
        } catch (error) {
            reject(error)
        }
    })
    return p
}

const await = (arg) => (onResolved, onRejected) => {
  const innerPromise = onRejected ? Promise.resolve(arg).catch(onRejected).then(onResolved, onRejected)
    : Promise.resolve(arg).then(onResolved, onRejected)
  return innerPromise
}
查看原文

赞 0 收藏 0 评论 0

万年打野易大师 发布了文章 · 2020-12-02

自动生成10+种简历模板,IT精简

一个可以帮助你快速生成漂亮简历的工具——best-resume-ever,它基于Vue和LESS创建,可以生成到处PDF格式的简历

项目地址:https://github.com/salomonelli/best-resume-ever

1、 首先,你需要克隆存储库

git clone https://github.com/salomonelli/best-resume-ever.git

2、 运行 npm install(一般还是用cnpm i)
3、 在 resume/ 目录中自定义编辑简历,诸如将图片换成个人证件照
4、 用npm run dev进行预览,在浏览器中打开(http:// localhost:8080/home)。该页面将显示一些简历预览。
5、 用 npm run export 导出简历,所有简历将导出至/pdf文件内。

查看原文

赞 0 收藏 0 评论 0