chenwl

chenwl 查看完整档案

广州编辑  |  填写毕业院校  |  填写所在公司/组织 youyoucuocuo.top 编辑
编辑

平坦的路面上曲折前行

个人动态

chenwl 赞了文章 · 2月26日

nodejs 终端打印进度条

1. 场景导入

当我们对大量文件进行批量处理的时候(例如:上传/下载、保存、编译等),常常希望知道当前进展如何,或者失败(成功)的任务有多少;当我们的代码或程序已经发布,用户在执行安装的过程中,一个合适的(终端/命令行)进度条可以准确反映安装的步骤和进程,提升程序的可用性,一定程度缓解用户在等待中的烦恼……

2. 基本原理

首先,在终端打印出文本是件比较容易的事情。
那么使用简单的文本和符号,就够自己拼凑出命令行的效果(下面例子):

文件已上传: 43.60% █████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 150/344

当然,进度条的效果可以根据需要自己设计啦,我这里只是给大家一个参考。

这里,我将打印命令行的方法构造成一个工具模块 progress-bar.js,具体实现如下 :-)

// 这里用到一个很实用的 npm 模块,用以在同一行打印文本
var slog = require('single-line-log').stdout;

// 封装的 ProgressBar 工具
function ProgressBar(description, bar_length){
  // 两个基本参数(属性)
  this.description = description || 'Progress';       // 命令行开头的文字信息
  this.length = bar_length || 25;                     // 进度条的长度(单位:字符),默认设为 25

  // 刷新进度条图案、文字的方法
  this.render = function (opts){
    var percent = (opts.completed / opts.total).toFixed(4);    // 计算进度(子任务的 完成数 除以 总数)
    var cell_num = Math.floor(percent * this.length);             // 计算需要多少个 █ 符号来拼凑图案

    // 拼接黑色条
    var cell = '';
    for (var i=0;i<cell_num;i++) {
      cell += '█';
    }

    // 拼接灰色条
    var empty = '';
    for (var i=0;i<this.length-cell_num;i++) {
      empty += '░';
    }

    // 拼接最终文本
    var cmdText = this.description + ': ' + (100*percent).toFixed(2) + '% ' + cell + empty + ' ' + opts.completed + '/' + opts.total;
    
    // 在单行输出文本
    slog(cmdText);
  };
}

// 模块导出
module.exports = ProgressBar;

3. Run 起来

基于上面的实现,先说一下这个 progress-bar.js 的用法:

// 引入工具模块
var ProgressBar = require('./progress_bar');

// 初始化一个进度条长度为 50 的 ProgressBar 实例
var pb = new ProgressBar('下载进度', 50);

// 这里只是一个 pb 的使用示例,不包含任何功能
var num = 0, total = 200;
function downloading() {
  if (num <= total) {
    // 更新进度条
    pb.render({ completed: num, total: total });

    num++;
    setTimeout(function (){
      downloading();
    }, 500)
  }
}
downloading();

run 一下上面的代码,执行效果如下:

原创文章,转载请注明出处

查看原文

赞 6 收藏 5 评论 1

chenwl 发布了文章 · 2月20日

defer-promise搞定异步弹窗组件

最近在看vue组件库 Plain UI 时,发现一个比较有趣的异步弹框组件写法,操作如下:

<div class="dialog">
    <input type="text" name="message">
    <button type="button">cancel</button>
    <button type="button">confirm</button>
</div>
(async ()=>{
    let message = await openDialog();
    console.log("弹窗信息",message)
})()

在异步函数中打开弹窗 openDialog 方法,当用户点击 confirm 按钮后,弹窗关闭,返回输入框信息。

openDialog方法可以很方便的通过promise实现,不过在看组件库源码时,发现对方是用defer 实现的,在promise兼容性还不是很好的时代 JQuery 就已经有 deferred.promise() 方法了,这里顺便也做了温习。

defer方法:

const defer = () => {
    const def = {}
    def.promise = new Promise((resolve, reject) => {
      def.resolve = resolve
      def.reject = reject
    })
    return def
}

defer方法其实返回的也是一个promise,并且将 resolvereject 方法拆开,这样我们就可以选择在适当的时机调用 resolve 或者 reject 方法了。

const dialogController = () => {
  let dfd = null

  const confirmBtn = document.getElementById('confirm')
  // 点击确定按钮
  confirmBtn.addEventListener('click', () => {
  // 隐藏弹窗
    dialogEl.hide()
  // resolve输入框信息给用户
    dfd.resolve(inputEl.value)
  })

  return () => {
    dfd = defer()
    dialogEl.show()
    return dfd.promise
  }
}

获得打开弹窗promise方法:

const openDialog = dialogController()

控制弹窗的打开,在异步函数中如果用户点击了弹窗确定按钮,关闭弹窗,获得输入信息。

const controlBtn = document.getElementById('control')
controlBtn.addEventListener('click', async () => {
  const message = await openDialog()
  console.log("弹窗输入框信息:",message)
})

这种方式可以方便我们封装常用的业务组件,之前在看 axios.cancel 源码时里面也是使用这种套路,灵活且实用。

通过 defer 方式实现的弹窗代码:

<html>
  <head>
    <title>defer promise</title>
    <style>
      .dialog {
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
        display: flex;
        position: fixed;
        align-items: center;
        pointer-events: none;
        justify-content: center;
      }
      .dialog .mask {
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        position: absolute;
        opacity: 0;
        transition: 0.3s;
        background-color: rgba(0, 0, 0, 0.4);
      }

      .dialog-content {
        padding: 20px;
        transition: 0.2s;
        opacity: 0;
        transform: scale(0.95);
        background-color: #fff;
      }

      .dialog.visible {
        pointer-events: all;
      }
      .dialog.visible .mask {
        opacity: 1;
      }
      .dialog.visible .dialog-content {
        opacity: 1;
        transform: scale(1);
      }
    </style>
  </head>
  <body>
    <div class="container">
      <button id="control">显示弹窗</button>
      <div class="dialog" id="dialog">
        <div class="mask" onclick="this.parentNode.classList.remove('visible')"></div>
        <div class="dialog-content">
          <input type="text" id="content" />
          <button id="confirm">确定</button>
        </div>
      </div>
    </div>

    <script>
      const defer = () => {
        const def = {}
        def.promise = new Promise((resolve, reject) => {
          def.resolve = resolve
          def.reject = reject
        })

        return def
      }

      ;(() => {
        const inputEl = document.getElementById('content')
        const dialogEl = document.getElementById('dialog')
        dialogEl.show = () => dialogEl.classList.add('visible')
        dialogEl.hide = () => dialogEl.classList.remove('visible')

        const dialogController = () => {
          let dfd = null
          const confirmBtn = document.getElementById('confirm')
          confirmBtn.addEventListener('click', () => {
            dialogEl.hide()
            dfd.resolve(inputEl.value)
          })

          return () => {
            dfd = defer()
            dialogEl.show()
            return dfd.promise
          }
        }

        const openDialog = dialogController()
        const controlBtn = document.getElementById('control')
        controlBtn.addEventListener('click', async () => {
          const message = await openDialog()
          console.log('弹窗输入框信息:', message)
        })
      })()
    </script>
  </body>
</html>
查看原文

赞 0 收藏 0 评论 0

chenwl 赞了文章 · 2月4日

前端mock完美解决方案实战

写在前面,本文阅读需要一定Nodejs的相关知识,因为会扩展webpack的相关功能,并且实现需要遵守一定约定和Ajax封装。沉淀的脚手架也放到Github上供给同学参考React-Starter, 使用手册还没写完善, 整体思路和React还是Vue无关,如果对大家有收获记得Star下。
它有这些功能:

  • 开发打包有不同配置
  • eslint 验证
  • 代码风格统一
  • commit 规范验证
  • 接口mock
  • 热更新
  • 异步组件

Mock功能介绍

市面上讲前端mock怎么做的文章很多,整体上阅读下来的没有一个真正站在前端角度上让我觉得强大和易用的。下面就说下我期望的前端mock要有哪些功能:

  1. mock功能和前端代码解耦
  2. 一个接口支持多种mock情况
  3. 无需依赖另外的后端服务和第三方库
  4. 能在network看到mock接口的请求且能区分
  5. mock数据、接口配置和页面在同一个目录下
  6. mock配置改变无需重启前端dev
  7. 生产打包可以把mock数据注入到打包的js中走前端mock
  8. 对于后端已有的接口也能快速把Response数据转化为mock数据

上面的这些功能我讲其中几点的作用:

对于第7点的作用是后续项目开发完成,在完全没有开发后端服务的情况下,也可以进行演示。这对于一些ToB定制的项目来沉淀项目地图(案例)很有作用。
对于第8点在开发环境后端服务经常不稳定下,不依赖后端也能做页面开发,核心是能实现一键生成mock数据。

配置解耦

耦合情况

什么是前端配置解耦,首先让我们看下平时配置耦合情况有哪些:

  • webpack-dev后端测试环境变了需要改git跟踪的代码
  • dev和build的时候 需要改git跟踪的代码
  • 开发的时候想这个接口mock 需要改git跟踪的代码 mockUrl ,mock?

如何解决

前端依赖的配置解耦的思路是配置文件conf.json是在dev或build的时候动态生成的,然后该文件在前端项目引用:

├── config
│   ├── conf.json                                    # git 不跟踪
│   ├── config.js                                    # git 不跟踪
│   ├── config_default.js
│   ├── index.js
│   └── webpack.config.js
├── jsconfig.json
├── mock.json                                            # git 不跟踪

webpack配置文件引入js的配置,生成conf.json

// config/index.js
const _ = require("lodash");
let config = _.cloneDeep(require("./config_default"))
try {
  const envConfig = require('./config') // eslint-disable-line
  config = _.merge(config, envConfig);
} catch (e) {
    // 
}
module.exports = config;

默认使用config_default.js 的内容,如果有config.js 则覆盖,开发的时候复制config_default.js 为config.js 后续相关配置可以修改config.js即可。

// config/config_default.js
const pkg = require("../package.json");
module.exports = {
  projectName: pkg.name,
  version: pkg.version,
  port: 8888,
  proxy: {
    "/render-server/api/*": {
      target: `http://192.168.1.8:8888`,
      changeOrigin: true, // 支持跨域请求
      secure: true, // 支持 https
    },
  },
  ...
  conf: {
    dev: {
      title: "前端模板",
      pathPrefix: "/react-starter", // 统一前端路径前缀
      apiPrefix: "/api/react-starter", //
      debug: true,
      delay: 500,    // mock数据模拟延迟
      mock: {
        // "global.login": "success",
        // "global.loginInfo": "success",
      }
    },
    build: {
      title: "前端模板",
      pathPrefix: "/react-starter",
      apiPrefix: "/api/react-starter",
      debug: false,
      mock: {}
    }
  }
};

在开发或打包的时候根据环境变量使用conf.dev或conf.build 生成conf.json文件内容

// package.json
{
  "name": "react-starter",
  "version": "1.0.0",
  "description": "react前端开发脚手架",
  "main": "index.js",
  "scripts": {
    "start": "webpack-dev-server --config './config/webpack.config.js' --open --mode development",
    "build": "cross-env BUILD_ENV=VERSION webpack --config './config/webpack.config.js' --mode production --progress --display-modules && npm run tar",
    "build-mock": "node ./scripts/build-mock.js "
  },
  ...
}

指定webpack路径是./config/webpack.config.js

然后在webpack.config.js中引入配置并生成conf.json文件

// config/webpack.config.js
const config = require('.')
const env = process.env.BUILD_ENV ? 'build' : 'dev'
const confJson = env === 'build' ? config.conf.build : config.conf.dev
fs.writeFileSync(path.join(__dirname, './conf.json'),  JSON.stringify(confGlobal, null, '\t'))

引用配置

src/common/utils.jsx文件中暴露出配置项,配置也可以通过window.conf来覆盖

// src/common/utils.jsx
import conf from '@/config/conf.json'
export const config = Object.assign(conf, window.conf)

然后就可以在各个页面中使用

import {config} from '@src/common/utils'
class App extends Component {
  render() {
    return (
      <Router history={history}>
        <Switch>
          <Route path={`${config.pathPrefix}`} component={Home} />
          <Redirect from="/" to={`${config.pathPrefix}`} />
        </Switch>
      </Router>
    )
  }
}
ReactDOM.render(
     <App />,
  document.getElementById('root'),
)

Mock实现

效果

为了实现我们想要的mock的相关功能,首先是否开启mock的配置解耦可以通过上面说的方式来实现,我们一般在页面异步请求的时候都会目录定义一个io.js的文件, 里面定义了当前页面需要调用的相关后端接口:

// src/pages/login/login-io.js
import {createIo} from '@src/io'

const apis = {
  // 登录
  login: {
    method: 'POST',
    url: '/auth/login',
  },
  // 登出
  logout: {
    method: 'POST',
    url: '/auth/logout',
  },
}
export default createIo(apis, 'login') // 对应login-mock.json

上面定义了登录和登出接口,我们希望对应开启的mock请求能使用当前目录下的login-mock.json文件的内容

// src/pages/login/login-mock.json
{
    "login": {
        "failed": {
            "success": false,
            "code": "ERROR_PASS_ERROR",
            "content": null,
            "message": "账号或密码错误!"
        },
        "success": {
            "success": true,
            "code": 0,
            "content": {
                "name": "admin",
                "nickname": "超级管理员",
                "permission": 15
            },
            "message": ""
        }
    },
    "logout": {
        "success": {
            "success": true,
            "code": 0,
            "content": null,
            "message": ""
        }
    }
}

在调用logout登出这个Ajax请求的时候且我们的conf.json中配置的是"login.logout": "success" 就返回login-mock.json中的login.success 的内容,配置没有匹配到就请求转发到后端服务。

// config/conf.json
{
    "title": "前端后台模板",
    "pathPrefix": "/react-starter",
    "apiPrefix": "/api/react-starter",
    "debug": true,
    "delay": 500,
    "mock": {
        "login.logout": "success"
    }
}

这是我们最终要实现的效果,这里有一个约定:项目目录下所有以-mock.jsom文件结尾的文件为mock文件,且文件名不能重复

思路

在webpack配置项中devServer的proxy配置接口的转发设置,接口转发使用了功能强大的 http-proxy-middleware 软件包, 我们约定proxy的配置格式是:

  proxy: {
    "/api/react-starter/*": {
      target: `http://192.168.90.68:8888`,
      changeOrigin: true,
      secure: true,
      // onError: (),
      // onProxyRes,
      // onProxyReq  
    },
  },

它有几个事件触发的配置:

  • option.onError 出现错误
  • option.onProxyRes 后端响应后
  • option.onProxyReq 请求转发前
  • option.onProxyReqWs
  • option.onOpen
  • option.onClose

所以我们需要定制这几个事情的处理,主要是请求转发前和请求处理后

onProxyReq

想在这里来实现mock的处理, 如果匹配到了mock数据我们就直接响应,就不转发请求到后端。 怎么做呢: 思路是依赖请求头,dev情况下前端在调用的时候能否注入约定好的请求头 告诉我需要寻找哪个mock数据项, 我们约定Header:

  • mock-key 来匹配mock文件如login-mock.json的内容, 如login
  • mock-method 来匹配对应文件内容的方法项 如logout

然后conf.json中mock配置寻找到具体的响应项目如:"login.logout": "success/failed"的内容

onProxyRes

如果调用了真实的后端请求,就把请求的响应数据缓存下来,缓存到api-cache目录下文件格式mock-key.mock-method.json

├── api-cache                                    # git 不跟踪
│   ├── login.login.json
│   └── login.logout.json
// api-cache/global.logout.json
{
    "success": {
        "date": "2020-11-17 05:32:17",
        "method": "POST",
        "path": "/render-server/api/logout",
        "url": "/render-server/api/logout",
        "resHeader": {
            "content-type": "application/json; charset=utf-8",
      ...
        },
        "reqHeader": {
            "host": "127.0.0.1:8888",
            "mock-key": "login",
            "mock-method": "logout"
      ...
        },
        "query": {},
        "reqBody": {},
        "resBody": {
            "success": true,
            "code": 0,
            "content": null,
            "message": ""
        }
    }
}

这样做的目的是为了后续实现一键生成mock文件。

前端接口封装

使用

上面我们看到定义了接口的io配置:

// src/pages/login/login-io.js
import {createIo} from '@src/io'

const apis = {
  // 登录
  login: {
    method: 'POST',
    url: '/auth/login',
  },
  // 登出
  logout: {
    method: 'POST',
    url: '/auth/logout',
  },
}
export default createIo(apis, 'login') // login注册到header的mock-key

我们在store中使用

// src/pages/login/login-store.js

import {observable, action, runInAction} from 'mobx'
import io from './login-io'
// import {config, log} from './utils'

export class LoginStore {
  // 用户信息
  @observable userInfo
  // 登陆操作
  @action.bound
  async login(params) {
    const {success, content} = await io.login(params)
    if (!success) return
    runInAction(() => {
      this.userInfo = content
    })
  }
}
export default LoginStore

通过 createIo(apis, 'login') 的封装在调用的时候就可以非常简单的来传递请求参数,简单模式下会判断参数是到body还是到query中。 复杂的也可以支持比如可以header,query, body等这里不演示了。

createIo 请求封装

这个是前端接口封装的关键地方,也是mock请求头注入的地方

// src/io/index.jsx
import {message, Modal} from 'antd'
import {config, log, history} from '@src/common/utils'
import {ERROR_CODE} from '@src/common/constant'
import creatRequest from '@src/common/request'
let mockData = {}
try {
  // eslint-disable-next-line global-require, import/no-unresolved
  mockData = require('@/mock.json')
} catch (e) {
  log(e)
}

let reloginFlag = false
// 创建一个request
export const request = creatRequest({
  // 自定义的请求头
  headers: {'Content-Type': 'application/json'},
  // 配置默认返回数据处理
  action: (data) => {
    // 统一处理未登录的弹框
    if (data.success === false && data.code === ERROR_CODE.UN_LOGIN && !reloginFlag) {
      reloginFlag = true
      // TODO 这里可能统一跳转到 也可以是弹窗点击跳转
      Modal.confirm({
        title: '重新登录',
        content: '',
        onOk: () => {
          // location.reload()
          history.push(`${config.pathPrefix}/login?redirect=${window.location.pathname}${window.location.search}`)
          reloginFlag = false
        },
      })
    }
  },
  // 是否错误显示message
  showError: true,
  message,
  // 是否以抛出异常的方式 默认false {success: boolean判断}
  throwError: false,
  // mock 数据请求的等待时间
  delay: config.delay,
  // 日志打印
  log,
})

// 标识是否是简单传参数, 值为true标识复杂封装
export const rejectToData = Symbol('flag')

/**
 * 创建请求IO的封装
 * @param ioContent {any { url: string method?: string mock?: any apiPrefix?: string}}
  }
 * @param name mock数据的对应文件去除-mock.json后的
 */
export const createIo = (ioContent, name = '') => {
  const content = {}
  Object.keys(ioContent).forEach((key) => {
    /**
     * @param {baseURL?: string, rejectToData?: boolean,  params?: {}, query?: {}, timeout?: number, action?(data: any): any, headers?: {},  body?: any, data?: any,   mock?: any}
     * @returns {message, content, code,success: boolean}
     */
    content[key] = async (options = {}) => {
      // 这里判断简单请求封装 rejectToData=true 表示复杂封装
      if (!options[rejectToData]) {
        options = {
          data: options,
        }
      }
      delete options[rejectToData]
      if (
        config.debug === false &&
        name &&
        config.mock &&
        config.mock[`${name}.${key}`] &&
        mockData[name] &&
        mockData[name][key]
      ) { // 判断是否是生产打包 mock注入到代码中
        ioContent[key].mock = JSON.parse(JSON.stringify(mockData[name][key][config.mock[`${name}.${key}`]]))
      } else if (name && config.debug === true) { //注入 mock请求头
        if (options.headers) {
          options.headers['mock-key'] = name
          options.headers['mock-method'] = key
        } else {
          options.headers = {'mock-key': name, 'mock-method': key}
        }
      }
      const option = {...ioContent[key], ...options}

      option.url = ((option.apiPrefix ? option.apiPrefix : config.apiPrefix) || '') + option.url

      return request(option)
    }
  })
  return content
}

这里对request也做进一步的封装,配置项设置了一些默认的处理设置。比如通用的请求响应失败的是否有一个message, 未登录的情况是否有一个弹窗提示点击跳转登陆页。如果你想定义多个通用处理可以再创建一个request2和createIo2。

request封装axios

是基于axios的二次封装, 并不是非常通用,主要是在约定的请求失败和成功的处理有定制,如果需要可以自己修改使用。

import axios from 'axios'

// 配置接口参数
// declare interface Options {
//   url: string
//   baseURL?: string
//   // 默认GET
//   method?: Method
//   // 标识是否注入到data参数
//   rejectToData?: boolean
//   // 是否直接弹出message 默认是
//   showError?: boolean
//   // 指定 回调操作 默认登录处理
//   action?(data: any): any
//   headers?: {
//     [index: string]: string
//   }
//   timeout?: number
//   // 指定路由参数
//   params?: {
//     [index: string]: string
//   }
//   // 指定url参数
//   query?: any
//   // 指定body 参数
//   body?: any
//   // 混合处理 Get到url, delete post 到body, 也替换路由参数 在createIo封装
//   data?: any
//   mock?: any
// }
// ajax 请求的统一封装
// TODO 1. 对jsonp请求的封装 2. 重复请求

/**
 * 返回ajax 请求的统一封装
 * @param Object option 请求配置
 * @param {boolean} opts.showError 是否错误调用message的error方法
 * @param {object} opts.message  包含 .error方法 showError true的时候调用
 * @param {boolean} opts.throwError 是否出错抛出异常
 * @param {function} opts.action  包含 自定义默认处理 比如未登录的处理
 * @param {object} opts.headers  请求头默认content-type: application/json
 * @param {number} opts.timeout  超时 默认60秒
 * @param {number} opts.delay   mock请求延迟
 * @returns {function} {params, url, headers, query, data, mock} data混合处理 Get到url, delete post 到body, 也替换路由参数 在createIo封装
 */
export default function request(option = {}) {
  return async (optionData) => {
    const options = {
      url: '',
      method: 'GET',
      showError: option.showError !== false,
      timeout: option.timeout || 60 * 1000,
      action: option.action,
      ...optionData,
      headers: {'X-Requested-With': 'XMLHttpRequest', ...option.headers, ...optionData.headers},
    }
    // 简单请求处理
    if (options.data) {
      if (typeof options.data === 'object') {
        Object.keys(options.data).forEach((key) => {
          if (key[0] === ':' && options.data) {
            options.url = options.url.replace(key, encodeURIComponent(options.data[key]))
            delete options.data[key]
          }
        })
      }
      if ((options.method || '').toLowerCase() === 'get' || (options.method || '').toLowerCase() === 'head') {
        options.query = Object.assign(options.data, options.query)
      } else {
        options.body = Object.assign(options.data, options.body)
      }
    }
    // 路由参数处理
    if (typeof options.params === 'object') {
      Object.keys(options.params).forEach((key) => {
        if (key[0] === ':' && options.params) {
          options.url = options.url.replace(key, encodeURIComponent(options.params[key]))
        }
      })
    }
    // query 参数处理
    if (options.query) {
      const paramsArray = []
      Object.keys(options.query).forEach((key) => {
        if (options.query[key] !== undefined) {
          paramsArray.push(`${key}=${encodeURIComponent(options.query[key])}`)
        }
      })
      if (paramsArray.length > 0 && options.url.search(/\?/) === -1) {
        options.url += `?${paramsArray.join('&')}`
      } else if (paramsArray.length > 0) {
        options.url += `&${paramsArray.join('&')}`
      }
    }
    if (option.log) {
      option.log('request  options', options.method, options.url)
      option.log(options)
    }
    if (options.headers['Content-Type'] === 'application/json' && options.body && typeof options.body !== 'string') {
      options.body = JSON.stringify(options.body)
    }
    let retData = {success: false}
    // mock 处理
    if (options.mock) {
      retData = await new Promise((resolve) =>
        setTimeout(() => {
          resolve(options.mock)
        }, option.delay || 500),
      )
    } else {
      try {
        const opts = {
          url: options.url,
          baseURL: options.baseURL,
          params: options.params,
          method: options.method,
          headers: options.headers,
          data: options.body,
          timeout: options.timeout,
        }
        const {data} = await axios(opts)
        retData = data
      } catch (err) {
        retData.success = false
        retData.message = err.message
        if (err.response) {
          retData.status = err.response.status
          retData.content = err.response.data
          retData.message = `浏览器请求非正常返回: 状态码 ${retData.status}`
        }
      }
    }

    // 自动处理错误消息
    if (options.showError && retData.success === false && retData.message && option.message) {
      option.message.error(retData.message)
    }
    // 处理Action
    if (options.action) {
      options.action(retData)
    }
    if (option.log && options.mock) {
      option.log('request response:', JSON.stringify(retData))
    }
    if (option.throwError && !retData.success) {
      const err = new Error(retData.message)
      err.code = retData.code
      err.content = retData.content
      err.status = retData.status
      throw err
    }
    return retData
  }
}
一键生成mock

根据api-cache下的接口缓存和定义的xxx-mock.json文件来生成。

# "build-mock": "node ./scripts/build-mock.js"
# 所有:
npm run build-mock mockAll 
# 单个mock文件:
npm run build-mock login
# 单个mock接口:
npm run build-mock login.logout
# 复杂 
npm run build-mock login.logout user

具体代码参考build-mock.js

mock.json文件生成

为了在build打包的时候把mock数据注入到前端代码中去,使得mock.json文件内容尽可能的小,会根据conf.json的配置项来动态生成mock.json的内容,如果build里面没有开启mock项,内容就会是一个空json数据。 当然后端接口代理处理内存中也映射了一份该mock.json的内容。这里需要做几个事情:

  • 根据配置动态生成mock.json的内容
  • 监听config文件夹 判断关于mock的配置项是否有改变重新生成mock.json
// scripts/webpack-init.js 在wenpack配置文件中初始化
const path = require('path')
const fs = require('fs')
const {syncWalkDir} = require('./util')
let confGlobal = {}
let mockJsonData = {}
exports.getConf = () => confGlobal
exports.getMockJson =() => mockJsonData

/**
 * 初始化项目的配置 动态生成mock.json和config/conf.json
 * @param {string} env  dev|build
 */
 exports.init = (env = process.env.BUILD_ENV ? 'build' : 'dev') => {
   
  delete require.cache[require.resolve('../config')]
  const config  = require('../config')
  const confJson = env === 'build' ? config.conf.build : config.conf.dev
  confGlobal = confJson
  // 1.根据环境变量来生成
  fs.writeFileSync(path.join(__dirname, '../config/conf.json'),  JSON.stringify(confGlobal, null, '\t'))
  buildMock(confJson)
 }
 
 // 生成mock文件数据
 const buildMock = (conf) => {
  // 2.动态生成mock数据 读取src文件夹下面所有以 -mock.json结尾的文件 存储到io/index.json文件当中
  let mockJson = {}
  const mockFiles = syncWalkDir(path.join(__dirname, '../src'), (file) => /-mock.json$/.test(file))
  console.log('build mocks: ->>>>>>>>>>>>>>>>>>>>>>>')
  mockFiles.forEach((filePath) => {
    const p = path.parse(filePath)
    const mockKey = p.name.substr(0, p.name.length - 5)
    console.log(mockKey, filePath)
    if (mockJson[mockKey]) {
      console.error(`有相同的mock文件名称${p.name} 存在`, filePath)
    }
    delete require.cache[require.resolve(filePath)]
    mockJson[mockKey] = require(filePath)
  })
  // 如果是打包环境, 最小化mock资源数据
  const mockMap = conf.mock || {}
  const buildMockJson = {}
  Object.keys(mockMap).forEach((key) => {
    const [name, method] = key.split('.')
    if (mockJson[name][method] && mockJson[name][method][mockMap[key]]) {
      if (!buildMockJson[name]) buildMockJson[name] = {}
      if (!buildMockJson[name][method]) buildMockJson[name][method] = {}
      buildMockJson[name][method][mockMap[key]] = mockJson[name][method][mockMap[key]]
    }
  })
  mockJsonData = buildMockJson
  fs.writeFileSync(path.join(__dirname, '../mock.json'), JSON.stringify(buildMockJson, null, '\t'))
 }
 
 // 监听配置文件目录下的config.js和config_default.js
const confPath = path.join(__dirname, '../config')

if ((env = process.env.BUILD_ENV ? 'build' : 'dev') === 'dev') {
  fs.watch(confPath, async (event, filename) => {
    if (filename === 'config.js' || filename === 'config_default.js') {
      delete require.cache[path.join(confPath, filename)]
      delete require.cache[require.resolve('../config')]
      const config  = require('../config')
      // console.log('config', JSON.stringify(config))
      const env = process.env.BUILD_ENV ? 'build' : 'dev'
      const confJson = env === 'build' ? config.conf.build : config.conf.dev
      if (JSON.stringify(confJson) !== JSON.stringify(confGlobal)) {
        this.init()
      }
    }
  });
}

接口代理处理

onProxyReq和onProxyRes

实现上面思路里面说的onProxyReq和onProxyRes 响应处理

util.js

// scripts/api-proxy-cache 
const fs = require('fs')
const path = require('path')
const moment = require('moment')
const {getConf, getMockJson} = require('./webpack-init')
const API_CACHE_DIR = path.join(__dirname, '../api-cache')
const {jsonParse, getBody} = require('./util')

fs.mkdirSync(API_CACHE_DIR,{recursive: true})

module.exports = {
  // 代理前处理
  onProxyReq: async (_, req, res) => {
    req.reqBody = await getBody(req)
    const {'mock-method': mockMethod, 'mock-key': mockKey} = req.headers
    // eslint-disable-next-line no-console
    console.log(`Ajax 请求: ${mockKey}.${mockMethod}`,req.method, req.url)
    // eslint-disable-next-line no-console
    req.reqBody && console.log(JSON.stringify(req.reqBody, null, '\t'))
    if (mockKey && mockMethod) {
      req.mockKey = mockKey
      req.mockMethod = mockMethod
      const conf = getConf()
      const mockJson = getMockJson()
      if (conf.mock && conf.mock[`${mockKey}.${mockMethod}`] && mockJson[mockKey] && mockJson[mockKey][mockMethod]) {
        // eslint-disable-next-line no-console
        console.log(`use mock data ${mockKey}.${mockMethod}:`, conf.mock[`${mockKey}.${mockMethod}`], 'color: green')
        res.mock = true
        res.append('isMock','yes')
        res.send(mockJson[mockKey][mockMethod][conf.mock[`${mockKey}.${mockMethod}`]])
      }
     
    }
  },
  // 响应缓存接口
  onProxyRes: async (res, req) => {
    const {method, url, query, path: reqPath, mockKey, mockMethod} = req
    
    if (mockKey && mockMethod && res.statusCode === 200) {
      
      let resBody = await getBody(res)
      resBody = jsonParse(resBody)
      const filePath = path.join(API_CACHE_DIR, `${mockKey}.${mockMethod}.json`)
      let  data = {}
      if (fs.existsSync(filePath)) {
        data = jsonParse(fs.readFileSync(filePath).toString())
      }
      const cacheObj = {
        date: moment().format('YYYY-MM-DD hh:mm:ss'),
        method,
        path: reqPath,
        url,
        resHeader: res.headers,
        reqHeader: req.headers,
        query,
        reqBody: await jsonParse(req.reqBody),
        resBody: resBody
      }
      if (resBody.success === false) {
        data.failed = cacheObj
      } else {
        data.success = cacheObj
      }
      // eslint-disable-next-line no-console
      fs.writeFile(filePath, JSON.stringify(data,'', '\t'), (err) => { err && console.log('writeFile', err)})
    }
  },
  // 后端服务没启的异常处理
  onError(err, req, res) {
    setTimeout(() => {
     if (!res.mock) {
       res.writeHead(500, {
         'Content-Type': 'text/plain',
       });
       res.end('Something went wrong. And we are reporting a custom error message.');
     }
   }, 10)
  }
}
webpack配置

在webpack配置中引入使用

const config = require('.')
// config/webpack.config.js
const {init} = require('../scripts/webpack-init');
init();
// 接口请求本地缓存
const apiProxyCache = require('../scripts/api-proxy-cache')
for(let key in config.proxy) {
  config.proxy[key] = Object.assign(config.proxy[key], apiProxyCache);
}

const webpackConf = {
  devServer: {
    contentBase: path.join(__dirname, '..'), // 本地服务器所加载的页面所在的目录
    inline: true,
    port: config.port,
    publicPath: '/',
    historyApiFallback: {
      disableDotRule: true,
      // 指明哪些路径映射到哪个html
      rewrites: config.rewrites,
    },
    host: '127.0.0.1',
    hot: true,
    proxy: config.proxy,
  },
}

总结

mock做好其实在我们前端实际中还是很有必要的,做过的项目如果后端被铲除了想要回忆就可以使用mock让项目跑起来,可以寻找一些实现的效果来进行代码复用。当前介绍的mock流程实现有很多定制的开发,但是真正完成后,团队中的成员只是使用还是比较简单配置即可。

关于前端项目部署我也分享了一个BFF 层,当前做的还不是很完善,也分享给大家参考

Render-Server 主要功能包含:

  • 一键部署 npm run deploy
  • 支持集群部署配置
  • 是一个文件服务
  • 是一个静态资源服务
  • 在线可视化部署前端项目
  • 配置热更新
  • 在线Postman及接口文档
  • 支持前端路由渲染, 支持模板
  • 接口代理及路径替换
  • Web安全支持 Ajax请求验证,Referer 校验
  • 支持插件开发和在线配置 可实现: 前端模板参数注入、请求头注入、IP白名单、接口mock、会话、第三方登陆等等
查看原文

赞 21 收藏 15 评论 6

chenwl 发布了文章 · 1月29日

axios取消功能详解

axios提供 CancelToken 方法可以取消正在发送中的接口请求。

官方提供了两种方式取消发送,第一种方式如下:

const CancelToken = axios.CancelToken;
const source = CancelToken.source();

axios.get('/user/12345', {
  cancelToken: source.token
}).catch(function (thrown) {
  if (axios.isCancel(thrown)) {
    console.log('Request canceled', thrown.message);
  } else {
    // handle error
  }
});

axios.post('/user/12345', {
  name: 'new name'
}, {
  cancelToken: source.token
})

// cancel the request (the message parameter is optional)
source.cancel('Operation canceled by the user.');

第二种方式如下:

const CancelToken = axios.CancelToken;
let cancel;

axios.get('/user/12345', {
  cancelToken: new CancelToken(function executor(c) {
    // An executor function receives a cancel function as a parameter
    cancel = c;
  })
});

// cancel the request
cancel();

官方实现取消功能的文件存放在 /lib/cancel/CancelToken.js

虽然代码不多,但是第一次看真是一头雾水,下面就来抽丝剥茧,一步步还原里面的实现逻辑。

分析

两种方式都调用了 CancekToken 这个构造函数,我们就先从这个构造函数开始。

分析:

第一种方式:

  • CancekToken 提供一个静态方法sourcesource方法返回tokencancel方法

第二种方式:

  • CancekToken 接收一个回调函数作为参数,回调函数接收cancel取消方法

第二种方式更容易入手,我们可以先实现构造函数CancekToken,再考虑第一种方式静态方法source的实现。

简易版 axios

首先我们写个简易版的axios,方便我们后面的分析和调试:

知识点:PromiseXMLHttpRequest

function axios(url,config){
  return new Promise((resolve,reject)=>{
    const xhr = new XMLHttpRequest();
    xhr.open(config.method || "GET",url);
    xhr.responseType = config.responseType || "json";
    xhr.onload = ()=>{
      if(xhr.readyState === 4 && xhr.status === 200){
        resolve(xhr.response);
      }else{
        reject(xhr)
      }
    };
    xhr.send(config.data ? JSON.stringify(config.data) : null);
  })
}

CancelToken

第二种方式中,我们可以看到 CancelToken 在配置参数cancelToken中实例化:

axios.get('/user/12345', {
  cancelToken: new CancelToken
});

所以在axios中,我们也会根据配置中是否包含cancelToken来取消发送:

function axios(url,config){
  return new Promise((resolve,reject)=>{
    const xhr = new XMLHttpRequest();
    ...
    if(config.cancelToken){
      // 如果存在 cancelToken 参数
      // xhr.abort() 终止发送任务
      // reject() 走reject方法
    }
    ...

回到配置参数,CancelToken接受一个回调函数作为参数,参数包含取消的cancel方法,我们初始化CancelToken方法如下:

function CancelToken(executor){
  let cancel = ()=>{};
  executor(cancel)
}

回到官方例子,例子中参数cancel方法被赋值给当前环境的cancel变量,于是当前环境cancel变量指向CancelToken方法中的cancel函数表达式。

let cancel;
axios.get('/user/12345', {
  cancelToken: new CancelToken(function executor(c) {
    cancel = c; // 指向CancelToken中的 cancel 方法
  })
});

接下来cancel方法一旦被执行,就能触发请求终止。

听起来是不是很熟悉!

就是发布订阅嘛!

这里官方源码巧妙的使用了Promise链式调用的方式实现,我们给CancelToken方法返回一个Promise方法:

function CancekToken(executor){
  let cancel = ()=>{};
  const promise = new Promise(resolve => cancel = resolve);
  executor(cancel);
  return promise;
}

接下来只要用户执行cancel方法,配置参数cancelToken获得的Promise方法就能响应了:

let cancel;
axios.get('/user/12345', {
  cancelToken: new CancelToken(function executor(c) {
    cancel = c; // 指向CancelToken中的 cancel 方法
  })
});
// 执行
+ cancel("canceled request");

axios中响应cancel(Promise)方法:

function axios(url,config){
  return new Promise((resolve,reject)=>{
    const xhr = new XMLHttpRequest();
    ...
    if(config.cancelToken){
+       config.cancelToken.then(reason=>{
+        xhr.abort();
+        reject(reason);
      })
    }
    ...

关键点是把 Promise.resolve 从函数内部抽出来,巧妙的实现了异步分离

到了这里,第二种方法的取消功能就基本实现了。

CancekToken.source

source作为 CancekToken 提供的静态方法,返回tokencancel 方法。

cancel方法跟前面的功能是一样的,可以理解成局部环境里面声明好cancel再抛出来。

我们再来看看第二种方式 token 在配置中的使用:

const CancelToken = axios.CancelToken;
const source = CancelToken.source();

axios.get('/user/12345', {
  cancelToken: source.token // token 返回的是CancelToken实例
})

根据前面的配置我们可以知道 source.token 实际上返回的是 CancelToken 实例。

了解 source 方法需要返回的对象功能后,就可以轻松实现source方法了:

CancekToken.source = function(){

  let cancel = ()=>{};
  const token = new CancekToken(c=>cancel = c);

  return {
    token,
    cancel
  }
}

axios.isCancel

通过上的代码我们知道,取消请求会走reject方法,在Promise中可以被catch到,不过我们还需要判断catch的错误是否来自取消方法,这里官方提供了isCancel方法判断:

axios.get('/user/12345', {
  cancelToken: source.token
}).catch(function (error) {
  // 判断是否 取消操作
  if (axios.isCancel(error)) {}
});

在js中我们可以通过instanceof判断是否来自某个构造函数的实例,这里新建Cancel方法来管理取消发送的信息:

function Cancel(reason){
  this.message = reason;
}

CancekToken.source 返回的cancel方法通过函数包装,实例化一个Cancel作为取消参数:

CancekToken.source = function(){
  
-  let cancel = ()=>{};
-  const token = new CancekToken(c=>cancel = c);
  
+ let resolve = ()=>{};
+  let token = new CancekToken(c=>resolve = c);

  return {
    token,
-    cancel,
+   cancel:(reason)=>{
+     // 实例化一个小 cancel,将 reason 传入
+     resolve(new Cancel(reason))
+   }
  }
}

最终Promise.catch到的参数来自实例Cancel,就可以很容易的判断error是否来自Cancel了:

function isCancel(error){
  return error instanceof Cancel
}
// 将 `isCancel` 绑定到 axios
axios.isCancel = isCancel

最后,官方还判断了CancelToken.prototype.throwIfRequested,如果调用了cancel方法,具有相同cancelToken配置的ajax请求也不会被发送,这里可以参考官方代码的实现。

全部代码

最后是全部代码实现:

function Cancel(reason) {
  this.message = reason
}

function CancekToken(executor) {
  let reason = null
  let resolve = null
  const cancel = message => {
    if(reason) return;
    reason = new Cancel(message);
    resolve(reason)
  }
  const promise = new Promise(r => (resolve = r))
  executor(cancel)
  return promise
}

CancekToken.source = function() {
  let cancel = () => {}
  let token = new CancekToken(c => (cancel = c))

  return {
    token,
    cancel
  }
}

const source = CancekToken.source()

axios('/simple/get', {
  cancelToken: source.token
}).catch(error => {
  if (axios.isCancel(error)) {
    console.log(error)
  }
})

source.cancel('canceled http request 1')

let cancel
axios('/simple/get', {
  cancelToken: new CancekToken(c => {
    cancel = c
  })
}).catch(error => {
  if (axios.isCancel(error)) {
    console.log(error)
  }
})
cancel('canceled http request 2')

function axios(url, config) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()
    xhr.open(config.method || 'GET', url)
    xhr.responseType = config.responseType || 'json'

    if (config.cancelToken) {
      config.cancelToken.then(reason => {
        xhr.abort()
        reject(reason)
      })
    }

    xhr.onload = () => {
      if (xhr.readyState === 4 && xhr.status === 200) {
        resolve(xhr.response)
      } else {
        reject(xhr)
      }
    }
    xhr.send(config.data ? JSON.stringify(config.data) : null)
  })
}

axios.isCancel = function(error) {
  return error instanceof Cancel
}

es6简易版本的实现:

export class Cancel {
    public reason:string;
    constructor(reason:string){
        this.reason = reason
    }
}
export function isCancel(error:any){
    return error instanceof Cancel;
}
export class CancelToken {
    public resolve:any;
    source(){
        return {
            token:new Promise(resolve=>{
                this.resolve = resolve;
            }),
            cancel:(reason:string)=>{
                this.resolve(new Cancel(reason).reason)
            }
        }
    }
}
查看原文

赞 1 收藏 0 评论 0

chenwl 赞了文章 · 1月28日

网易云音乐年度歌单的卡通形象联动制作

最近朋友圈被很多网易云音乐的年底歌单给刷屏了, 我也去看了我的年度歌单, 发现一个有意思的交互效果, 选择卡通形象, 通过滑动选择人物的不同头像,衣服,裤子 最终塑造成一个拥有独立个性的卡通形象.

界面效果预览

image

交互效果预览

image

image

制作素材

把每个滑动的图片进行了全屏截图, 然后通过图片处理工具去除背景, 制作成统一大小的png图片.

image
image
image

图片的卡通元素都是通过截图获取, 每个元素被处理成统一大小, 部分会有锯齿, 仅供参考. 这里头部比较特殊, 每个形象的头部大小不一, 这里取一个统一的截止线, 方便后面整合成整个形象. 其它类似,顶对齐即可.

分析交互的特点

1. 轮播图
2. 跨屏
3. 滑动循环
4. 部分衣服滑动会触发裤子的改变
5. 部分裤子滑动会触发衣服的改变
6. ...

image
轮播图代码

<div id="slide" class="bui-slide bui-slide-skin01"></div>
var uiSlide = bui.slide({
        id: "#slide",
        height: 320,
        // autopage: true, // 自动分页
        data: [{
            image: "images/banner01.png",
            url: "pages/ui_controls/bui.slide_title.html",
        }, {
            image: "images/banner02.png",
            url: "pages/ui_controls/bui.slide_title.html",
        }, {
            image: "images/banner03.png",
            url: "pages/ui_controls/bui.slide_title.html",
        }],
        loop: true, // 循环
    })

image

跨屏轮播图只需加上 cross:true 参数即可. 熟悉BUI的朋友, 一眼就能找到类似的效果, 跨屏轮播图 第1-第3的特点就解决了.

有意思的是第4点第5点, 轮播图切换的时候部分需要相互关联.

实现的核心思路:

  1. 页面有一个静态全屏轮播图, 用于点击下一步,上一步的整屏切换. 静态轮播图的好处是结构可以自定义.
  2. 首屏初始化三个跨屏轮播图, 用于头部,衣服,裤子的正常选择切换;
  3. 点击轮播图的时候, 切换激活状态, 非激活状态隐藏左右两个图片(隐藏通过css), 并禁止滑动 ;
  4. 当滑动选中以后,分别把头部,衣服,裤子的图片地址,索引 缓存在 bui.store (轮播图的to回调里面);
  5. 通过bui.store 创建衣服跟裤子的关联 conection 字段, 当检测到滑动的图片有配套裤子的时候,自动滑动下一个轮播图到指定位置;
  6. 点击下一步去到第2屏, 用于展示刚刚选中的数据;
// 衣服
const cartoonBody = bui.slide({
    id: "#cartoonBody",
    height: 320,
    stopPropagation: false,
    autopage: false,
    cross: true,
    loop: true,
    data: this.$data.cartoon.body
}).on("to", function () {
    let index = this.index();
    // bui.store 读取的时候需要使用 this.$data.xxx ,如果使用 this.xxx 读取会导致最终的值不能设置正确.
    let img = that.$data.cartoon.body[index].image;
    // 设置
    that.profile.body.image = img;
    that.profile.body.index = index;

    // 检测衣服跟裤子的关系索引
    let item = bui.array.get(that.$data.conection, img, "body");
    let footindex = bui.array.index(that.$data.cartoon.foot, item.foot, "image");

    if (footindex >= 0 && that.$data.active[1] == "active-block") {
        // 操作裤子的实例, 跳转的时候, 由于loop:true, 这里的索引需要在真实的索引下+1 
        that.$data.distances[2].to(footindex + 1, "none")
    }

}).lock();// lock禁止滑动
// 裤子
const cartoonFoot = bui.slide({
    id: "#cartoonFoot",
    height: 320,
    stopPropagation: false,
    autopage: false,
    cross: true,
    loop: true,
    data: this.$data.cartoon.foot
}).on("to", function () {
    let index = this.index();
    let img = that.$data.cartoon.foot[index].image;
    that.profile.foot.image = img;
    that.profile.foot.index = index;

    // 检测衣服跟裤子的关系索引
    let item = bui.array.get(that.$data.conection, img, "foot");
    let bodyindex = bui.array.index(that.$data.cartoon.body, item.body, "image");
    if (bodyindex >= 0 && that.$data.active[2] == "active-block") {
        // 操作衣服的实例, 跳转的时候, 由于loop:true, 这里的索引需要在真实的索引下+1 
        that.$data.distances[1].to(bodyindex + 1, "none")
    }
}).lock();// lock禁止滑动

最终效果

image
image

github地址: https://github.com/imouou/BUI...

codepen地址: https://codepen.io/imouou/ful...

BUI专注移动开发, 灵活超出你的想象, 感谢您的阅读.

image.png

多页完整代码

<!DOCTYPE HTML>
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html;charset=UTF-8" />
    <title>BUI</title>
    <meta name="format-detection" content="telephone=no" />
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/buijs@latest/lib/latest/bui.css" />
    
<style>
    .cartoon-page main,
    .step-item {
        background-color: #f2c9bc;
        padding-top: .2rem;
    }
    .step-item {
        width: 100%;
        height: 100%;
    }
    .cartoon-page h1,
    .cartoon-page p {
        text-align: center;
        color: #675553;
    }
    .cartoon-wrap .bui-slide {
        margin-bottom: .2rem;
    }
    .cartoon-wrap .bui-slide-img{
        width: 4rem;
        height: 3.2rem;
        background-color: #e2b4a3;
        border-radius: .2rem;
    }
    .cartoon-wrap .active-block .bui-slide-img{
        background-color: #fff;
    }
    .cartoon-wrap .active-block .bui-cross-prev,
    .cartoon-wrap .active-block .bui-cross-next{
        visibility: visible;
    }
    .cartoon-wrap  .bui-cross-prev,
    .cartoon-wrap  .bui-cross-next{
        visibility: hidden;
    }
    .cartoon-wrap  .bui-cross-prev .bui-slide-img,
    .cartoon-wrap  .bui-cross-next .bui-slide-img{
        background-color: rgba(255,255,255,.3);
    }
    .bui-btn-step {
        width: 1.4rem;
        height: 1.4rem;
        line-height: 1.4rem;
        color: #fff;
        background-color: #f5433b;
        border: 3px solid rgba(255,255,255,0.8);
        padding: 0;
        margin-bottom: .2rem;
    }
    .bui-slide-cross .bui-cross-next .bui-slide-img, 
    .bui-slide-cross .li-next .bui-slide-img{
        margin-left: 0;
    }
    .bui-slide-cross .bui-cross-prev .bui-slide-img, 
    .bui-slide-cross .li-prev .bui-slide-img{
        margin-right: 0;
    }
    .bui-slide-fullscreen>.bui-slide-main>ul>li img.cartoonhead ,
    .bui-slide-fullscreen>.bui-slide-main>ul>li img.cartoonbody,
    .bui-slide-fullscreen>.bui-slide-main>ul>li img.cartoonfoot {
        display: block;
        width:3.2rem ;
        height:3.2rem ;
    }
    .cartoonhead {
        position: relative;
        z-index: 3;
    }
    .cartoonbody {
        margin-top: -1.1rem;
        position: relative;
        z-index: 2;
    }
    .cartoonfoot {
        margin-top: -1.1rem;
        position: relative;
        z-index: 1;
    }
</style>
</head>
<body>
<!-- HTML Begin-->

<!-- 这里还是一个标准的BUI页面 -->
<div class="bui-page bui-box-vertical cartoon-page">
    <header></header>
    <main>
        <!-- 静态轮播图 -->
        <div id="uiSlide" class="bui-slide">
            <div class="bui-slide-main">
                <ul>
                    <li>
                        <!-- 垂直布局 -->
                        <div class="step-item bui-box-center bui-box-vertical fullheight">

                            <div class="span1">
                                <h1>设置形象, 开启年度报告</h1>
                                <p>左右切换选择造型</p>
                                <div class="bui-box bui-box-vertical cartoon-wrap">
                                    <div class="span1" b-class="cartoons.active.0" b-click="cartoons.activeBlock(0)">
                                        <div id="cartoonHead" class="bui-slide"></div>
                                    </div>
                                    <div class="span1" b-class="cartoons.active.1" b-click="cartoons.activeBlock(1)">
                                        <div id="cartoonBody" class="bui-slide"></div>
                                    </div>
                                    <div class="span1" b-class="cartoons.active.2" b-click="cartoons.activeBlock(2)">
                                        <div id="cartoonFoot" class="bui-slide"></div>
                                    </div>
                                    <!-- <div class="span1" b-class="cartoons.active.3" b-click="cartoons.activeBlock(3)">
                                        <div id="cartoonDeco" class="bui-slide"></div>
                                    </div> -->
                                </div>
                            </div>
                            <div class="container-y">
                                <div class="bui-btn-step ring" b-click="cartoons.next">下一步</div>
                            </div>
                        </div>
                    </li>
                    <li style="display:none;">
                        <!-- 垂直布局 -->
                        <div class="step-item bui-box-center bui-box-vertical fullheight">
                            <!-- 最终形象 -->
                            <div class="span1">
                                <div class="bui-box-center">
                                    <div class="wrap-img">
                                        ![](cartoons.profile.head.image)
                                        ![](cartoons.profile.body.image)
                                        ![](cartoons.profile.foot.image)
                                    </div>
                                </div>
                            </div>
                            <div class="container-y">
                                <div class="bui-btn-step ring" b-click="cartoons.prev">上一步</div>
                            </div>
                        </div>
                    </li>
                </ul>
            </div>
        </div>
    </main>
</div>
<!-- HTML End-->
    <!-- 依赖库 手机调试的js引用顺序如下 -->
    <script data-original="https://cdn.jsdelivr.net/npm/buijs@latest/lib/zepto.js"></script>
    <script data-original="https://cdn.jsdelivr.net/npm/buijs@latest/lib/latest/bui.js"></script>
    <script>
        bui.ready(function () {
            // 这里写业务及控件初始化, 一个页面只能有一个bui.ready
            // 页面跳转的全屏轮播图
            const uiSlideStep = bui.slide({
                id: "#uiSlide",
                autopage: false,
                fullscreen: true,
                swipe: false,
                loop: false
            })
            // 初始化数据行为存储
            const bs = bui.store({
                el: `.bui-page`,
                scope: "cartoons",
                data: {
                    // 衣服裤子的关系, 部分衣服关联裤子, 裤子关联衣服
                    conection: [{
                        body: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/body/body02.png",
                        foot: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/foot/foot01.png"
                    }, {
                        body: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/body/body03.png",
                        foot: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/foot/foot05.png"
                    }, {
                        body: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/body/body12.png",
                        foot: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/foot/foot08.png"
                    }, {
                        body: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/body/body13.png",
                        foot: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/foot/foot07.png"
                    }, {
                        body: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/body/body14.png",
                        foot: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/foot/foot06.png"
                    }],
                    distances: [], // 存储滑动的实例
                    active: {
                        0: "active-block",
                        1: "",
                        2: "",
                    },
                    profile: {
                        // 个人形象的存储
                        head: {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/head/head01.png",
                            index: 0,
                        },
                        body: {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/body/body01.png",
                            index: 0,
                        },
                        foot: {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/foot/foot01.png",
                            index: 0,
                        },
                        deco: {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/deco/deco01.png",
                            index: 0,
                        }
                    },
                    cartoon: {
                        active: 0, // 激活的slide, 默认头部
                        head: [{
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/head/head01.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/head/head02.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/head/head03.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/head/head04.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/head/head05.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/head/head06.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/head/head07.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/head/head08.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/head/head09.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/head/head10.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/head/head11.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/head/head12.png",
                        }],
                        body: [{
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/body/body01.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/body/body02.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/body/body03.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/body/body04.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/body/body05.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/body/body06.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/body/body07.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/body/body08.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/body/body09.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/body/body10.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/body/body11.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/body/body12.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/body/body13.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/body/body14.png",
                        }],
                        foot: [{
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/foot/foot01.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/foot/foot02.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/foot/foot03.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/foot/foot04.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/foot/foot05.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/foot/foot06.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/foot/foot07.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/foot/foot08.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/foot/foot09.png",
                        }],
                        deco: [{
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/deco/deco01.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/deco/deco02.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/deco/deco03.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/deco/deco04.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/deco/deco05.png",
                        }, {
                            image: "https://gitee.com/imouou/bui-case-cartoon/raw/main/src/images/cartoon/deco/deco06.png",
                        }],
                    },
                },
                methods: {
                    activeBlock(index) {
                        for (let i = 0; i < Object.keys(this.$data.active).length; i++) {
                            this.active[i] = "";
                            this.$data.distances[i].lock();
                        }
                        // 给激活的滑动图加上样式,区别其它两个
                        this.active[index] = "active-block";
                        this.$data.distances[index].unlock();
                    },
                    next() {
                        uiSlideStep.next();
                    },
                    prev() {
                        uiSlideStep.prev();
                    }
                },
                mounted: function () {
                    // 焦点图 js 初始化:
                    let that = this;
                    const cartoonHead = bui.slide({
                        id: "#cartoonHead",
                        height: 320,
                        autopage: false,
                        stopPropagation: false,
                        cross: true,
                        loop: true,
                        data: this.$data.cartoon.head
                    }).on("to", function () {
                        let index = this.index();
                        // bui.store 读取的时候需要使用 this.$data.xxx ,如果使用 this.xxx 读取会导致最终的值不能设置正确.
                        let img = that.$data.cartoon.head[index].image;
                        // 设置
                        that.profile.head.index = index;
                        that.profile.head.image = img;

                    })

                    const cartoonBody = bui.slide({
                        id: "#cartoonBody",
                        height: 320,
                        stopPropagation: false,
                        autopage: false,
                        cross: true,
                        loop: true,
                        data: this.$data.cartoon.body
                    }).on("to", function () {
                        let index = this.index();
                        // bui.store 读取的时候需要使用 this.$data.xxx ,如果使用 this.xxx 读取会导致最终的值不能设置正确.
                        let img = that.$data.cartoon.body[index].image;
                        // 设置
                        that.profile.body.image = img;
                        that.profile.body.index = index;

                        // 检测衣服跟裤子的关系索引
                        let item = bui.array.get(that.$data.conection, img, "body");
                        let footindex = bui.array.index(that.$data.cartoon.foot, item.foot, "image");

                        if (footindex >= 0 && that.$data.active[1] == "active-block") {
                            // 操作裤子的实例, 跳转的时候, 由于loop:true, 这里的索引需要在真实的索引下+1 
                            that.$data.distances[2].to(footindex + 1, "none")
                        }

                    }).lock();

                    const cartoonFoot = bui.slide({
                        id: "#cartoonFoot",
                        height: 320,
                        stopPropagation: false,
                        autopage: false,
                        cross: true,
                        loop: true,
                        data: this.$data.cartoon.foot
                    }).on("to", function () {
                        let index = this.index();
                        let img = that.$data.cartoon.foot[index].image;
                        that.profile.foot.image = img;
                        that.profile.foot.index = index;

                        // 检测衣服跟裤子的关系索引
                        let item = bui.array.get(that.$data.conection, img, "foot");
                        let bodyindex = bui.array.index(that.$data.cartoon.body, item.body, "image");
                        if (bodyindex >= 0 && that.$data.active[2] == "active-block") {
                            // 操作衣服的实例, 跳转的时候, 由于loop:true, 这里的索引需要在真实的索引下+1 
                            that.$data.distances[1].to(bodyindex + 1, "none")
                        }
                    }).lock();

                    // const cartoonDeco = bui.slide({
                    //     id: "#cartoonDeco",
                    //     height: 320,
                    //     stopPropagation: false,
                    //     autopage: false,
                    //     cross: true,
                    //     loop: true,
                    //     data: this.$data.cartoon.deco
                    // }).on("to", function () {
                    //     let index = this.index();

                    //     that.profile.deco.image = that.$data.cartoon.deco[index].image
                    //     that.profile.deco.index = index;
                    // }).to(0, "none").lock();

                    // 添加实例,跟cartoon.active 的数值对应.
                    this.distances.push(cartoonHead, cartoonBody, cartoonFoot);

                }
            })
        })
    </script>
</body>
</html>
查看原文

赞 1 收藏 0 评论 0

chenwl 发布了文章 · 1月5日

dva-loading使用总结

在开发异步加载的功能时,为提高用户体验一般会显示加载提示,最近在使用umi做项目时接触到dva-loading,对全局和局部组件的异步加载控制还是非常方便的。

在umi中使用

安装和配置

安装:

$ npm install dva-loading -S

进入 src/app.js 进行 运行时dva配置

import createLoading from "dva-loading"

export const dva = {
  plugins: [createLoading()]
}

models

models 文件夹下新建 count.js,输入下面内容:

const delay = (ms)=>new Promise(r=>setTimeout(r,ms))

export default {
    namespace:"count",
    state:{
        count:1,
    },
    effects:{
        *add(action,{put,call}){
            yield call(delay,1000);
            yield put({type:"change",payload:Math.random()})
        }
    },
    reducers:{
        change(state,{payload}){
            return {count:state.count+payload}
        }
    }
}

组件中使用

新建 Count.js组件进行测试:

import React from "react"
import { connect } from "dva"

function Count({ dispatch, count, loading }) {
    
    const isLoading = loading.models.count;
    // 单独对 effects 控制
    // const isLoading = loading.effects["count/add"]
    // 对多个 effects 控制
    // const isLoading = loading.effects["count/add"] || loading.effects["count/minus"] || false;

  return (
    <div>
      {isLoading ? <p>加载中...</p> : <p>{count}</p>}
      <button onClick={() => dispatch({ type: "count/add" })}>+</button>
    </div>
  )
}

export default connect((state) => ({ ...state.count, loading: state.loading }))(Count)

我们可以通过 state.loading 判断组件的 model甚至 effect 的状态。

dva-loading 源码

dva-loading

const SHOW = '@@DVA_LOADING/SHOW';
const HIDE = '@@DVA_LOADING/HIDE';
const NAMESPACE = 'loading';

function createLoading(opts = {}) {
  const namespace = opts.namespace || NAMESPACE;

  const { only = [], except = [] } = opts;
  if (only.length > 0 && except.length > 0) {
    throw Error('It is ambiguous to configurate `only` and `except` items at the same time.');
  }

  const initialState = {
    global: false,
    models: {},
    effects: {},
  };

  const extraReducers = {
    [namespace](state = initialState, { type, payload }) {
      const { namespace, actionType } = payload || {};
      let ret;
      switch (type) {
        case SHOW:
          ret = {
            ...state,
            global: true,
            models: { ...state.models, [namespace]: true },
            effects: { ...state.effects, [actionType]: true },
          };
          break;
        case HIDE: {
          const effects = { ...state.effects, [actionType]: false };
          const models = {
            ...state.models,
            [namespace]: Object.keys(effects).some(actionType => {
              const _namespace = actionType.split('/')[0];
              if (_namespace !== namespace) return false;
              return effects[actionType];
            }),
          };
          const global = Object.keys(models).some(namespace => {
            return models[namespace];
          });
          ret = {
            ...state,
            global,
            models,
            effects,
          };
          break;
        }
        default:
          ret = state;
          break;
      }
      return ret;
    },
  };

  function onEffect(effect, { put }, model, actionType) {
    const { namespace } = model;
    if (
      (only.length === 0 && except.length === 0) ||
      (only.length > 0 && only.indexOf(actionType) !== -1) ||
      (except.length > 0 && except.indexOf(actionType) === -1)
    ) {
      return function*(...args) {
        yield put({ type: SHOW, payload: { namespace, actionType } });
        yield effect(...args);
        yield put({ type: HIDE, payload: { namespace, actionType } });
      };
    } else {
      return effect;
    }
  }

  return {
    extraReducers,
    onEffect,
  };
}

export default createLoading;

@umijs/plugin-dva 接口实现

@umijs/plugin-dva 抛出的 useSelector 方法可以很方便的帮助我们获取models 层数据:

const { loading, count } = useSelector((stores) => ({ 
      loading: stores.loading, 
      count: stores.count 
    }))

通过 useDispatch 获取 dispatch 方法:

const dispatch = useDispatch()
const add = () => dispatch({ type: "count/add" })

修改状态:

import React from "react"
import { useDispatch, useSelector } from "dva"

function Count(props) {
  const dispatch = useDispatch()
  const add = () => dispatch({ type: "count/add" })

  const { loading, count } = useSelector((stores) => ({ 
      loading: stores.loading, 
      count: stores.count 
    }))
  const isLoading = loading.models.count

  return (
    <div>
      {isLoading ? <p>loading</p> : <p>{count.count}</p>}
      <button onClick={add}>+</button>
    </div>
  )
}

export default Count

全局 loading 控制

通过 useSelector方法得到 stores.loading.global,判断 models是否在loading中:

import React from 'react'
const {useSelector} = 'dva'
import {Spin} from 'antd'
const DemoPage = () => {
  const {loading} = useSelector(stores => ({
    loading: stores.loading
  }))
  return (
    <Spin spinning={loading.global}/>
  )
}

参考:

查看原文

赞 0 收藏 0 评论 0

chenwl 发布了文章 · 2020-12-24

nodejs篇-进程与集群cluster

我们启动一个服务、运行一个实例,就是开一个服务进程,Node.js 里通过 node app.js 开启一个服务进程,多进程就是进程的复制(fork),fork 出来的每个进程都拥有自己的独立空间地址、数据栈,一个进程无法访问另外一个进程里定义的变量、数据结构,只有建立了 IPC 通信,进程之间才可数据共享。

child_process

node.js中可以通过下面四种方式创建子进程:

  • child_process.spawn(command, args)
  • child_process.exec(command, options)
  • child_process.execFile(file, args[, callback])
  • child_process.fork(modulePath, args)

spawn

const {spawn} = require("child_process");
// 创建 文件
spawn("touch",["index.js"]);

spawn()会返回child-process子进程实例:

const {spawn} = require("child_process");
// cwd 指定子进程的工作目录,默认当前目录
const child = spawn("ls",["-l"],{cwd:__dirname});
// 输出进程信息
child.stdout.pipe(process.stdout);
console.log(process.pid,child.pid);

子进程同样基于事件机制(EventEmitter API),提供了一些事件:

  • exit:子进程退出时触发,可以得知进程退出状态(code和signal)
  • disconnect:父进程调用child.disconnect()时触发
  • error:子进程创建失败,或被kill时触发
  • close:子进程的stdio流(标准输入输出流)关闭时触发
  • message:子进程通过process.send()发送消息时触发,父子进程消息通信
close与exit的区别主要体现在多进程共享同一stdio流的场景,某个进程退出了并不意味着stdio流被关闭了

子进程具有可读流的特性,利用可读流实现find . -type f | wc -l,递归统计当前目录文件数量:

const { spawn } = require('child_process');

const find = spawn('find', ['.', '-type', 'f']);
const wc = spawn('wc', ['-l']);

find.stdout.pipe(wc.stdin);

wc.stdout.on('data', (data) => {
  console.log(`Number of files ${data}`);
});

exec

spawn()exec()方法的区别在于,exec()不是基于stream的,exec()会将传入命令的执行结果暂存到buffer中,再整个传递给回调函数。

spawn()默认不会创建shell去执行命令(性能上会稍好),而exec()方法执行是会先创建shell,所以可以在exec()方法中传入任意shell脚本。

const {exec} = require("child_process");

exec("node -v",(error,stdout,stderr)=>{
    if (error) console.log(error);
    console.log(stdout)
})
exec()方法因为可以传入任意shell脚本所以存在安全风险。

spawn()方法默认不会创建shell去执行传入的命令(所以性能上稍微好一点),不过可以通过参数实现:

const { spawn } = require('child_process');
const child = spawn('node -v', {
  shell: true
});
child.stdout.pipe(process.stdout);

这种做法的好处是,既能支持shell语法,也能通过stream IO进行标准输入输出。

execFile

const {execFile} = require("child_process");

execFile("node",["-v"],(error,stdout,stderr)=>{
    console.log({ error, stdout, stderr })
    console.log(stdout)
})

通过可执行文件路径执行:

const {execFile} = require("child_process");

execFile("/Users/.nvm/versions/node/v12.1.0/bin/node",["-v"],(error,stdout,stderr)=>{
    console.log({ error, stdout, stderr })
    console.log(stdout)
})

fork

fork()方法可以用来创建Node进程,并且父子进程可以互相通信

//master.js
const {fork} = require("child_process");
const worker = fork("worker.js");

worker.on("message",(msg)=>{
    console.log(`from worder:${msg}`)
});
worker.send("this is master");

// worker.js
process.on("message",(msg)=>{
    console.log("worker",msg)
});
process.send("this is worker");

利用fork()可以用来处理计算量大,耗时长的任务:

const longComputation = () => {
  let sum = 0;
  for (let i = 0; i < 1e10; i++) {
    sum += i;
  };
  return sum;
};

longComputation方法拆分到子进程中,这样主进程的事件循环不会被耗时计算阻塞:

const http = require('http');
const { fork } = require('child_process');

const server = http.createServer();

server.on('request', (req, res) => {
  if (req.url === '/compute') {
    // 将计算量大的任务,拆分到子进程中处理
    const compute = fork('compute.js');
    compute.send('start');
    compute.on('message', sum => {
        // 收到子进程任务后,返回
      res.end(`Sum is ${sum}`);
    });
  } else {
    res.end('Ok')
  }
});

server.listen(3000);

进程间通信IPC

每个进程各自有不同的用户地址空间,任何一个进程的全局变量在另一个进程中都看不到,所以进程之间要交换数据必须通过内核,在内核中开辟一块缓冲区,进程1把数据从用户空间拷到内核缓冲区,进程2再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信(IPC,InterProcess Communication)

进程之间可以借助内置的IPC机制通信

父进程:

  • 接收事件process.on('message')
  • 发送信息给子进程master.send()

子进程:

  • 接收事件process.on('message')
  • 发送信息给父进程process.send()

fork 多进程

nodejs中的多进程是 多进程 + 单线程 的模式
// master.js. 
process.title = 'node-master'
const net = require("net");
const {fork} = require("child_process");

const handle = net._createServerHandle("127.0.0.1",3000);

for(let i=0;i<4;i++){
    fork("./worker.js").send({},handle);
}

// worker.js
process.title = 'worker-master';

const net = require("net");

process.on("message",(msg,handle)=>start(handle));

const buf = "hello nodejs";
const res= ["HTTP/1.1 200 ok","content-length:"+buf.length].join("\r\n")+"\r\n\r\n"+buf;

function start(server){
    server.listen();
    let num=0;
    server.onconnection = function(err,handle){
        num++;
        console.log(`worker ${process.pid} num ${num}`);
        let socket = new net.Socket({handle});
        socket.readable = socket.writable = true
        socket.end(res);
    }
}

运行node master.js,这里可以使用测试工具 Siege

siege -c 20 -r 10 http://localhost:3000

-c 并发量,并发数为20人 -r 是重复次数, 重复10次

这种创建进程的特点是:

  • 在一个服务上同时启动多个进程
  • 每个进程运行同样的代码(start方法)
  • 多个进程可以同时监听一个端口(3000)

不过每次请求过来交给哪个worker处理,master并不清楚,我们更希望master能够掌控全局,将请求指定给worker,我们做下面的改造:

//master.js
process.title = 'node-master'
const net =require("net");
const {fork} = require("child_process");

// 定义workers变量,保存子进程worker
let workers = [];
for(let i=0;i<4;i++){
    workers.push(fork("./worker.js"));
}
const handle = net._createServerHandle("0.0.0.0", 3000)
handle.listen();
// master控制请求
handle.onconnection = function(err,handle){
    let worker = workers.pop();
    // 将请求传递给子进程
    worker.send({},handle);
    workers.unshift(worker);
}

// worker.js
process.title = 'worker-master';
const net = require("net")
process.on("message", (msg, handle) => start(handle))

const buf = "hello nodejs"
const res = ["HTTP/1.1 200 ok", "content-length:" + buf.length].join("\r\n") + "\r\n\r\n" + buf

function start(handle) {
  console.log(`get a connection on worker,pid = %d`, process.pid)
  let socket = new net.Socket({ handle })
  socket.readable = socket.writable = true
  socket.end(res)
}

Cluster 多进程

Node.js 官方提供的 Cluster 模块不仅充分利用机器 CPU 内核开箱即用的解决方案,还有助于 Node 进程增加可用性的能力,Cluster模块是对多进程服务能力的封装。
// master.js
const cluster = require("cluster");
const numCPUS = require("os").cpus().length;

if(cluster.isMaster){
    console.log(`master start...`)
    for(let i=0;i<numCPUS;i++){
        cluster.fork();
    };

    cluster.on("listening",(worker,address)=>{
        console.log(`master listing worker pid ${worker.process.pid} address port:${address.port}`)
    })

}else if(cluster.isWorker){
    require("./wroker.js")
}
//wroker.js
const http = require("http");
http.createServer((req,res)=>res.end(`hello`)).listen(3000)

进程重启和守护

进程重启

为了增加服务器的可用性,我们希望实例在出现崩溃或者异常退出时,能够自动重启。

//master.js
const cluster = require("cluster")
const numCPUS = require("os").cpus().length

if (cluster.isMaster) {
  console.log("master start..")
  for (let i = 0; i < numCPUS; i++) {
      cluster.fork()
    }
  cluster.on("listening", (worker, address) => {
    console.log("listening worker pid " + worker.process.pid)
  })
  cluster.on("exit", (worker, code, signal) => {
      // 子进程出现异常或者奔溃退出
    if (code !== 0 && !worker.exitedAfterDisconnect) {
      console.log(`工作进程 ${worker.id} 崩溃了,正在开始一个新的工作进程`)
      // 重新开启子进程
      cluster.fork()
    }
  })
} else if (cluster.isWorker) {
  require("./server")
}
const http = require("http")
const server = http.createServer((req, res) => {
    // 随机触发错误
  if (Math.random() > 0.5) {
      throw new Error(`worker error pid=${process.pid}`)
  }
  res.end(`worker pid:${process.pid} num:${num}`)
}).listen(3000)

如果请求抛出异常而结束子进程,主进程能够监听到结束事件,重启开启子进程。

上面的重启只是简单处理,真正项目中要考虑到的就很多了,这里可以参考egg的多进程模型和进程间通讯

下面是来自文章Node.js进阶之进程与线程更全面的例子:

// master.js
const {fork} = require("child_process");
const numCPUS = require("os").cpus().length;

const server = require("net").createServer();
server.listen(3000);
process.title="node-master";

const workers = {};
const createWorker = ()=>{
    const worker = fork("worker.js");
    worker.on("message",message=>{
        if(message.act==="suicide"){
            createWorker();
        }
    })

    worker.on("exit",(code,signal)=>{
        console.log('worker process exited,code %s signal:%s',code,signal);
        delete workers[worker.pid];
    });

    worker.send("server",server);
    workers[worker.pid] = worker;
    console.log("worker process created,pid %s ppid:%s", worker.pid, process.ppid)
}

for (let i = 0; i < numCPUS; i++) {
  createWorker()
}

process.once("SIGINT",close.bind(this,"SIGINT")); // kill(2) Ctrl+C
process.once("SIGQUIT", close.bind(this, "SIGQUIT")) // kill(3) Ctrl+l
process.once("SIGTERM", close.bind(this, "SIGTERM")) // kill(15) default
process.once("exit", close.bind(this))

function close(code){
    console.log('process exit',code);
    if(code!=0){
        for(let pid in workers){
            console.log('master process exit,kill worker pid:',pid);
            workers[pid].kill("SIGINT");
        }
    };
    process.exit(0);
}
//worker.js
const http=require("http");
const server = http.createServer((req,res)=>{
    res.writeHead(200,{"Content-Type":"text/plain"});
    res.end(`worker pid:${process.pid},ppid:${process.ppid}`)
    throw new Error("worker process exception!");
});

let worker;
process.title = "node-worker";
process.on("message",(message,handle)=>{
    if(message==="server"){
        worker = handle;
        worker.on("connection",socket=>{
            server.emit("connection",socket)
        })
    }
})
process.on("uncaughtException",(error)=>{
    console.log('some error')
    process.send({act:"suicide"});
    worker.close(()=>{
        console.log(process.pid+" close")
        process.exit(1);
    })
})

这个例子考虑更加周到,通过uncaughtException捕获子进程异常后,发送信息给主进程重启,并在链接关闭后退出。

进程守护

pm2可以使服务在后台运行不受终端的影响,这里主要通过两步处理:

  • options.detached:为true时运行子进程在父进程退出后继续运行
  • unref() 方法可以断绝跟父进程的关系,使父进程退出后子进程不会跟着退出
const { spawn } = require("child_process")

function startDaemon() {
  const daemon = spawn("node", ["daemon.js"], {
    // 当前工作目录
    cwd: __dirname,
    // 作为独立进程存在
    detached: true,
    // 忽视输入输出流
    stdio: "ignore",
  })
  console.log(`守护进程 ppid:%s pid:%s`, process.pid, daemon.pid)
  // 断绝父子进程关系
  daemon.unref()
}

startDaemon()
// daemon.js
const fs = require("fs")
const {Console} = require("console");
// 输出日志
const logger = new Console(fs.createWriteStream("./stdout.log"),fs.createWriteStream("./stderr.log"));
// 保持进程一直在后台运行
setInterval(()=>{
    logger.log("daemon pid:",process.pid,"ppid:",process.ppid)
},1000*10);

// 生成关闭文件
fs.writeFileSync("./stop.js", `process.kill(${process.pid}, "SIGTERM")`)

参考链接

查看原文

赞 0 收藏 0 评论 0

chenwl 发布了文章 · 2020-12-11

async/await和forEach

async/await使用起来非常棒,但是在Array.forEach()中却存在陷阱。

问题描述

例如说下面的代码:

const waitFor = (ms) => new Promise(r => setTimeout(r, ms));
[1, 2, 3].forEach(async (num) => {
  await waitFor(1000);
  console.log(num);
});
console.log('Done');

在控制台执行上面代码结果如下(node.js版本≥ 13.0.0):

$ node forEach.js
$ Done
$ 1
$ 2
$ 3

问题分析

console.log(num)的结果在最后才输出,并且是同时输出。

我们创建一个forEach()方法理解下到底发生了什么事:

Array.prototype.forEach = function (callback) {
  // 这里迭代我们的数组
  for (let index = 0; index < this.length; index++) {
    // 每次迭代执行 callback 方法,传入对应参数
    callback(this[index], index, this);
  }
};
forEach() 的 polyfill 实现可以参考:MDN-Array.prototype.forEach()

可以看到,callback并没有在async/await中执行,所以Promise的结果在最后才打印。

解决问题

我们可以用自己写的asyncForEach方法代替forEach

asyncForEach([1, 2, 3], async (num) => {
  await waitFor(50);
  console.log(num);
})
console.log('Done');

执行上面的代码,可以看到下面的结果:

$ node forEach.js
$ Done
$ 1
$ 2
$ 3

结果好像没什么区别,不过console.log(num)已经在async/await中执行了,我们也可以看到setTimeout的效果。

但是还不够理想,实际上asyncForEach返回的是一个Promise,因为它包裹在一个async函数中,我们可以等待它执行完成后再打印出最后的Done

const start = async () => {
  await asyncForEach([1, 2, 3], async (num) => {
    await waitFor(50);
    console.log(num);
  });
  console.log('Done');
}
start();

再次执行可以看到正确的结果:

$ node forEach.js
$ 1
$ 2
$ 3
$ Done
查看原文

赞 0 收藏 0 评论 0

chenwl 赞了文章 · 2020-12-11

Node.js进阶之进程与线程

进程与线程在服务端研发中是一个非常重要的概念,如果您在学习的时候对这一块感到混乱或者不是太理解,可以阅读下本篇内容,本篇在介绍进程和线程的概念之外,列举了很多 Demo 希望能从实战角度帮助您更好的去理解。

作者简介:五月君,Nodejs Developer,热爱技术、喜欢分享的 90 后青年,公众号 “Nodejs技术栈”,Github 开源项目 https://www.nodejs.red

快速导航

进程

进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础,进程是线程的容器(来自百科)。我们启动一个服务、运行一个实例,就是开一个服务进程,例如 Java 里的 JVM 本身就是一个进程,Node.js 里通过 node app.js 开启一个服务进程,多进程就是进程的复制(fork),fork 出来的每个进程都拥有自己的独立空间地址、数据栈,一个进程无法访问另外一个进程里定义的变量、数据结构,只有建立了 IPC 通信,进程之间才可数据共享。

关于进程通过一个简单的 Node.js Demo 来验证下,执行以下代码 node process.js,开启一个服务进程
// process.js
const http = require('http');

http.createServer().listen(3000, () => {
    process.title = '测试进程 Node.js' // 进程进行命名
    console.log(`process.pid: `, process.pid); // process.pid: 20279
});

以下为 Mac 系统自带的监控工具 “活动监视器” 所展示的效果,可以看到我们刚开启的 Nodejs 进程 20279

图片描述

线程

线程是操作系统能够进行运算调度的最小单位,首先我们要清楚线程是隶属于进程的,被包含于进程之中。一个线程只能隶属于一个进程,但是一个进程是可以拥有多个线程的。

同一块代码,可以根据系统CPU核心数启动多个进程,每个进程都有属于自己的独立运行空间,进程之间是不相互影响的。同一进程中的多条线程将共享该进程中的全部系统资源,如虚拟地址空间,文件描述符和信号处理等。但同一进程中的多个线程有各自的调用栈(call stack),自己的寄存器环境(register context),自己的线程本地存储(thread-local storage),线程又有单线程和多线程之分,具有代表性的 JavaScript、Java 语言。

单线程

单线程就是一个进程只开一个线程,想象一下一个痴情的少年,对一个妹子一心一意用情专一。

Javascript 就是属于单线程,程序顺序执行,可以想象一下队列,前面一个执行完之后,后面才可以执行,当你在使用单线程语言编码时切勿有过多耗时的同步操作,否则线程会造成阻塞,导致后续响应无法处理。你如果采用 Javascript 进行编码时候,请尽可能的使用异步操作。

一个计算耗时造成线程阻塞的例子

先看一段例子,运行下面程序,浏览器执行 http://127.0.0.1:3000/compute 大约每次需要 15657.310ms,也就意味下次用户请求需要等待 15657.310ms,下文 Node.js 进程创建一节 将采用 child_process.fork 实现多个进程来处理。

// compute.js
const http = require('http');
const [url, port] = ['127.0.0.1', 3000];

const computation = () => {
    let sum = 0;
    console.info('计算开始');
    console.time('计算耗时');

    for (let i = 0; i < 1e10; i++) {
        sum += i
    };

    console.info('计算结束');
    console.timeEnd('计算耗时');
    return sum;
};

const server = http.createServer((req, res) => {
    if(req.url == '/compute'){
        const sum = computation();

        res.end(`Sum is ${sum}`);
    }

    res.end(`ok`);
});

server.listen(port, url, () => {
    console.log(`server started at http://${url}:${port}`);
});

单线程使用总结

  • Node.js 虽然是单线程模型,但是其基于事件驱动、异步非阻塞模式,可以应用于高并发场景,避免了线程创建、线程之间上下文切换所产生的资源开销。
  • 如果你有需要大量计算,CPU 耗时的操作,开发时候要注意。

多线程

多线程就是没有一个进程只开一个线程的限制,好比一个风流少年除了爱慕自己班的某个妹子,还在想着隔壁班的漂亮妹子。Java 就是多线程编程语言的一种,可以有效避免代码阻塞导致的后续请求无法处理。

对于多线程的说明 Java 是一个很好的例子,看以下代码示例,我将 count 定义在全局变量,如果定义在 test 方法里,又会输出什么呢?
public class TestApplication {
    Integer count = 0;

    @GetMapping("/test")
    public Integer Test() {
        count += 1;
        return count;
    }

    public static void main(String[] args) {
        SpringApplication.run(TestApplication.class, args);
    }
}

运行结果,每次执行都会修改count值,所以,多线程中任何一个变量都可以被任何一个线程所修改。

1 # 第一次执行
2 # 第二次执行
3 # 第三次执行
我现在对上述代码做下修改将 count 定义在 test 方法里
public class TestApplication {
    @GetMapping("/test")
    public Integer Test() {
        Integer count = 0; // 改变定义位置
        count += 1;
        return count;
    }

    public static void main(String[] args) {
        SpringApplication.run(TestApplication.class, args);
    }
}

运行结果如下所示,每次都是 1,因为每个线程都拥有了自己的执行栈

1 # 第一次执行
1 # 第二次执行
1 # 第三次执行

多线程使用总结

多线程的代价还在于创建新的线程和执行期上下文线程的切换开销,由于每创建一个线程就会占用一定的内存,当应用程序并发大了之后,内存将会很快耗尽。类似于上面单线程模型中例举的例子,需要一定的计算会造成当前线程阻塞的,还是推荐使用多线程来处理,关于线程与进程的理解推荐阅读下 阮一峰:进程与线程的一个简单解释

Nodejs的线程与进程

Node.js 是 Javascript 在服务端的运行环境,构建在 chrome 的 V8 引擎之上,基于事件驱动、非阻塞I/O模型,充分利用操作系统提供的异步 I/O 进行多任务的执行,适合于 I/O 密集型的应用场景,因为异步,程序无需阻塞等待结果返回,而是基于回调通知的机制,原本同步模式等待的时间,则可以用来处理其它任务,在 Web 服务器方面,著名的 Nginx 也是采用此模式(事件驱动),Nginx 采用 C 语言进行编写,主要用来做高性能的 Web 服务器,不适合做业务。Web业务开发中,如果你有高并发应用场景那么 Node.js 会是你不错的选择。

在单核 CPU 系统之上我们采用 单进程 + 单线程 的模式来开发。在多核 CPU 系统之上,可以通过 child_process.fork 开启多个进程(Node.js 在 v0.8 版本之后新增了Cluster 来实现多进程架构) ,即 多进程 + 单线程 模式。注意:开启多进程不是为了解决高并发,主要是解决了单进程模式下 Node.js CPU 利用率不足的情况,充分利用多核 CPU 的性能。

Process

Node.js 中的进程 Process 是一个全局对象,无需 require 直接使用,给我们提供了当前进程中的相关信息。官方文档提供了详细的说明,感兴趣的可以亲自实践下 Process 文档

  • process.env:环境变量,例如通过 process.env.NODE_ENV 获取不同环境项目配置信息
  • process.nextTick:这个在谈及 Event Loop 时经常为会提到
  • process.pid:获取当前进程id
  • process.ppid:当前进程对应的父进程
  • process.cwd():获取当前进程工作目录
  • process.platform:获取当前进程运行的操作系统平台
  • process.uptime():当前进程已运行时间,例如:pm2 守护进程的 uptime 值
  • 进程事件:process.on('uncaughtException', cb) 捕获异常信息、process.on('exit', cb)进程推出监听
  • 三个标准流:process.stdout 标准输出、process.stdin 标准输入、process.stderr 标准错误输出

以上仅列举了部分常用到功能点,除了 Process 之外 Node.js 还提供了 child_process 模块用来对子进程进行操作,在下文 Nodejs进程创建一节 会讲述。

关于 Node.js 进程的几点总结

  • Javascript 是单线程,但是做为宿主环境的 Node.js 并非是单线程的。
  • 由于单线程原故,一些复杂的、消耗 CPU 资源的任务建议不要交给 Node.js 来处理,当你的业务需要一些大量计算、视频编码解码等 CPU 密集型的任务,可以采用 C 语言。
  • Node.js 和 Nginx 均采用事件驱动方式,避免了多线程的线程创建、线程上下文切换的开销。如果你的业务大多是基于 I/O 操作,那么你可以选择 Node.js 来开发。

Nodejs进程创建

Node.js 提供了 child_process 内置模块,用于创建子进程,更多详细信息可参考 Node.js 中文网 child_process

四种方式

  • child_process.spawn():适用于返回大量数据,例如图像处理,二进制数据处理。
  • child_process.exec():适用于小量数据,maxBuffer 默认值为 200 * 1024 超出这个默认值将会导致程序崩溃,数据量过大可采用 spawn。
  • child_process.execFile():类似 child_process.exec(),区别是不能通过 shell 来执行,不支持像 I/O 重定向和文件查找这样的行为
  • child_process.fork(): 衍生新的进程,进程之间是相互独立的,每个进程都有自己的 V8 实例、内存,系统资源是有限的,不建议衍生太多的子进程出来,通长根据系统 CPU 核心数设置。

方式一:spawn

child_process.spawn(command, args)

创建父子进程间通信的三种方式:

  • 让子进程的stdio和当前进程的stdio之间建立管道链接 child.stdout.pipe(process.stdout);
  • 父进程子进程之间共用stdio
  • 事件监听
const spawn = require('child_process').spawn;
const child = spawn('ls', ['-l'], { cwd: '/usr' }) // cwd 指定子进程的工作目录,默认当前目录

child.stdout.pipe(process.stdout);
console.log(process.pid, child.pid); // 主进程id3243 子进程3244

方式二:exec

const exec = require('child_process').exec;

exec(`node -v`, (error, stdout, stderr) => {
    console.log({ error, stdout, stderr })
    // { error: null, stdout: 'v8.5.0\n', stderr: '' }
})

方式三:execFile

const execFile = require('child_process').execFile;

execFile(`node`, ['-v'], (error, stdout, stderr) => {
    console.log({ error, stdout, stderr })
    // { error: null, stdout: 'v8.5.0\n', stderr: '' }
})

方式四:fork

const fork = require('child_process').fork;
fork('./worker.js'); // fork 一个新的子进程

fork子进程充分利用CPU资源

上文单线程一节 例子中,当 CPU 计算密度大的情况程序会造成阻塞导致后续请求需要等待,下面采用 child_process.fork 方法,在进行 cpmpute 计算时创建子进程,子进程计算完成通过 send 方法将结果发送给主进程,主进程通过 message 监听到信息后处理并退出。

fork_app.js
const http = require('http');
const fork = require('child_process').fork;

const server = http.createServer((req, res) => {
    if(req.url == '/compute'){
        const compute = fork('./fork_compute.js');
        compute.send('开启一个新的子进程');

        // 当一个子进程使用 process.send() 发送消息时会触发 'message' 事件
        compute.on('message', sum => {
            res.end(`Sum is ${sum}`);
            compute.kill();
        });

        // 子进程监听到一些错误消息退出
        compute.on('close', (code, signal) => {
            console.log(`收到close事件,子进程收到信号 ${signal} 而终止,退出码 ${code}`);
            compute.kill();
        })
    }else{
        res.end(`ok`);
    }
});

server.listen(3000, 127.0.0.1, () => {
    console.log(`server started at http://${127.0.0.1}:${3000}`);
});
fork_compute.js

针对 上文单线程一节 的例子需要进行计算的部分拆分出来单独进行运算。

const computation = () => {
    let sum = 0;
    console.info('计算开始');
    console.time('计算耗时');

    for (let i = 0; i < 1e10; i++) {
        sum += i
    };

    console.info('计算结束');
    console.timeEnd('计算耗时');
    return sum;
};

process.on('message', msg => {
    console.log(msg, 'process.pid', process.pid); // 子进程id
    const sum = computation();

    // 如果Node.js进程是通过进程间通信产生的,那么,process.send()方法可以用来给父进程发送消息
    process.send(sum);
})

Nodejs多进程架构模型

多进程架构解决了单进程、单线程无法充分利用系统多核 CPU 的问题,通过上文对 Node.js 进程有了初步的了解,本节通过一个 Demo 来展示如何启动一批 Node.js 进程来提供服务。

编写主进程

master.js 主要处理以下逻辑:

  • 创建一个 server 并监听 3000 端口。
  • 根据系统 cpus 开启多个子进程
  • 通过子进程对象的 send 方法发送消息到子进程进行通信
  • 在主进程中监听了子进程的变化,如果是自杀信号重新启动一个工作进程。
  • 主进程在监听到退出消息的时候,先退出子进程在退出主进程
// master.js
const fork = require('child_process').fork;
const cpus = require('os').cpus();

const server = require('net').createServer();
server.listen(3000);
process.title = 'node-master'

const workers = {};
const createWorker = () => {
    const worker = fork('worker.js')
    worker.on('message', function (message) {
        if (message.act === 'suicide') {
            createWorker();
        }
    })
    worker.on('exit', function(code, signal) {
        console.log('worker process exited, code: %s signal: %s', code, signal);
        delete workers[worker.pid];
    });
    worker.send('server', server);
    workers[worker.pid] = worker;
    console.log('worker process created, pid: %s ppid: %s', worker.pid, process.pid);
}

for (let i=0; i<cpus.length; i++) {
    createWorker();
}

process.once('SIGINT', close.bind(this, 'SIGINT')); // kill(2) Ctrl-C
process.once('SIGQUIT', close.bind(this, 'SIGQUIT')); // kill(3) Ctrl-\
process.once('SIGTERM', close.bind(this, 'SIGTERM')); // kill(15) default
process.once('exit', close.bind(this));

function close (code) {
    console.log('进程退出!', code);

    if (code !== 0) {
        for (let pid in workers) {
            console.log('master process exited, kill worker pid: ', pid);
            workers[pid].kill('SIGINT');
        }
    }

    process.exit(0);
}

工作进程

worker.js 子进程处理逻辑如下:

  • 创建一个 server 对象,注意这里最开始并没有监听 3000 端口
  • 通过 message 事件接收主进程 send 方法发送的消息
  • 监听 uncaughtException 事件,捕获未处理的异常,发送自杀信息由主进程重建进程,子进程在链接关闭之后退出
// worker.js
const http = require('http');
const server = http.createServer((req, res) => {
    res.writeHead(200, {
        'Content-Type': 'text/plan'
    });
    res.end('I am worker, pid: ' + process.pid + ', ppid: ' + process.ppid);
    throw new Error('worker process exception!'); // 测试异常进程退出、重建
});

let worker;
process.title = 'node-worker'
process.on('message', function (message, sendHandle) {
    if (message === 'server') {
        worker = sendHandle;
        worker.on('connection', function(socket) {
            server.emit('connection', socket);
        });
    }
});

process.on('uncaughtException', function (err) {
    console.log(err);
    process.send({act: 'suicide'});
    worker.close(function () {
        process.exit(1);
    })
})

测试

控制台执行 node master.js 可以看到已成功创建了四个工作进程

$ node master
worker process created, pid: 19280 ppid: 19279
worker process created, pid: 19281 ppid: 19279
worker process created, pid: 19282 ppid: 19279
worker process created, pid: 19283 ppid: 19279

打开活动监视器查看我们的进程情况,由于在创建进程时对进程进行了命名,很清楚的看到一个主进程对应多个子进程。

图片描述以上 Demo 简单的介绍了多进程创建、异常监听、重启等,但是做为企业级应用程序我们还需要考虑的更完善,例如:进程的重启次数限制、与守护进程结合、多进程模式下定时任务处理等,感兴趣的同学推荐看下阿里 Egg.js 多进程模式

守护进程

关于守护进程,是什么、为什么、怎么编写?本节将解密这些疑点

守护进程运行在后台不受终端的影响,什么意思呢?Node.js 开发的同学们可能熟悉,当我们打开终端执行 node app.js 开启一个服务进程之后,这个终端就会一直被占用,如果关掉终端,服务就会断掉,即前台运行模式。如果采用守护进程进程方式,这个终端我执行 node app.js 开启一个服务进程之后,我还可以在这个终端上做些别的事情,且不会相互影响。

创建步骤

  1. 创建子进程
  2. 在子进程中创建新会话(调用系统函数 setsid)
  3. 改变子进程工作目录(如:“/” 或 “/usr/ 等)
  4. 父进程终止

Node.js 编写守护进程 Demo 展示

index.js 文件里的处理逻辑使用 spawn 创建子进程完成了上面的第一步操作。设置 options.detached 为 true 可以使子进程在父进程退出后继续运行(系统层会调用 setsid 方法),参考 options_detached,这是第二步操作。options.cwd 指定当前子进程工作目录若不做设置默认继承当前工作目录,这是第三步操作。运行 daemon.unref() 退出父进程,参考 options.stdio,这是第四步操作。

// index.js
const spawn = require('child_process').spawn;

function startDaemon() {
    const daemon = spawn('node', ['daemon.js'], {
        cwd: '/usr',
        detached : true,
        stdio: 'ignore',
    });

    console.log('守护进程开启 父进程 pid: %s, 守护进程 pid: %s', process.pid, daemon.pid);
    daemon.unref();
}

startDaemon()

daemon.js 文件里处理逻辑开启一个定时器每 10 秒执行一次,使得这个资源不会退出,同时写入日志到子进程当前工作目录下

// /usr/daemon.js
const fs = require('fs');
const { Console } = require('console');

// custom simple logger
const logger = new Console(fs.createWriteStream('./stdout.log'), fs.createWriteStream('./stderr.log'));

setInterval(function() {
    logger.log('daemon pid: ', process.pid, ', ppid: ', process.ppid);
}, 1000 * 10);

守护进程实现 Node.js 版本 源码地址

运行测试

$ node index.js
守护进程开启 父进程 pid: 47608, 守护进程 pid: 47609

打开活动监视器查看,目前只有一个进程 47609,这就是我们需要进行守护的进程

图片描述

守护进程阅读推荐

守护进程总结

在实际工作中对于守护进程并不陌生,例如 PM2、Egg-Cluster 等,以上只是一个简单的 Demo 对守护进程做了一个说明,在实际工作中对守护进程的健壮性要求还是很高的,例如:进程的异常监听、工作进程管理调度、进程挂掉之后重启等等,这些还需要我们去不断思考。

查看原文

赞 3 收藏 2 评论 0

chenwl 发布了文章 · 2020-12-03

reactSSR实践总结

为了更深入地了解服务端渲染,所以动手搭了一个react-ssr的服务端渲染项目,因为项目中很少用到,这篇文章主要是对实现过程中的一些总结笔记,更详细的介绍推荐看 从零开始,揭秘React服务端渲染核心技术

服务端和客户端的渲染区别

  • 客户端渲染react:ReactDOM.render(component,el)
  • 服务端渲染react:ReactDom.renderToString(component)

服务端并没有dom元素,需要使用renderToString方法将组件转成html字符串返回。

不同的编写规范

客户端编写使用es6 Module规范,服务端使用使用的commonjs规范

解决问题

使用webpack对服务端代码进行打包,和打包客户端代码不同的是,服务端打包需要添加target:"node" 配置项和webpack-node-externals这个库:

与客户端打包不同,这里服务端打包webpack有两个点要注意:

  • 添加target:"node" 配置项,不将node自带的诸如path、fs这类的包打进去
  • 新增webpack-node-externals,忽视node_modules文件夹
var nodeExternals = require('webpack-node-externals');
...
module.exports = {
    ...
    target: 'node', // in order to ignore built-in modules like path, fs, etc.
    externals: [nodeExternals()], // in order to ignore all modules in node_modules folder
    ...
};

同构

renderToString方法返回的只是html字符串,js逻辑并没有生效,所以react组件在服务端完成html渲染后,也需要打包客户端需要的js交互代码:

import express from 'express';
import React from 'react';
import {renderToString} from 'react-dom/server';
import App from  './src/app';
const app = express();

// 静态文件夹,webpack打包后的js文件放置public下
app.use(express.static("public"))

app.get('/',function(req,res){
  // 生成html字符串
  const content = renderToString(<App/>);
  res.send(`
        <!doctype html>
        <html>
            <title>ssr</title>
            <body>
                <div id="root">${content}</div>
                // 绑定生成后的js文件
                <script data-original="/client.js"></script>
            </body> 
        </html>
    `);
});
app.listen(3000);
可以理解成,react代码在服务端生成html结构,在客户端执行js交互代码

同样在服务端也要编写一份同样App组件代码:

import React from 'react';
import {render} from 'react-dom';
import App from './app';
render(<App/>,document.getElementById("root"));

不过在服务端已经绑定好了元素在root节点,在客户端继续执行render方法,会清空已经渲染好的子节点,又重新生成子节点,控制台也会抛出警告:

Warning: render(): Calling ReactDOM.render() to hydrate server-rendered markup will stop working in React v17. Replace the ReactDOM.render() call with ReactDOM.hydrate() if you want React to attach to the server HTML.

这里推荐用ReactDOM.hydrate()取代ReactDOM.render()在服务端的渲染,两者的区别是:

ReactDOM.render()会将挂载dom节点的所有子节点全部清空掉,再重新生成子节点。而ReactDOM.hydrate()则会复用挂载dom节点的子节点,并将其与react的virtualDom关联上。

路由

客户端渲染路由一般使用react-routerBrowserRouter或者HashRouter,两者分别会使用浏览器的window.location对象和window.history对象处理路由,但是在服务端并没有window对象,这里react-router在服务端提供了StaticRouter

  • 服务端渲染使用StaticRouter,提供locationcontext参数
import {StaticRouter,Route} from 'react-router';
...
module.exports = (req,res)=>{
    const context = {} // 服务端才会有context,子组件通过props.staticContext获取
    const content = renderToString(
         <StaticRouter context={context} location={req.path}>
             <Route to="/" component={Home}></Route>
         </StaticRouter>         
     );
}
  • 客户端渲染使用BrowserRouter
import {BrowserRouter,Route} from 'react-router';
...
ReactDom.hydrate(
  <BrowserRouter>
        <Route to="/" component={Home}></Route>
  </BrowserRouter>
  document.getElementById("root")
)

前后端路由同构

前后端的路由基本相同,适合应该写成一份代码进行维护,这里使用react-router-config将路由配置化。

  • routes/index.js
import Home from "../containers/Home";
import App from "../containers/App";
import Profile from "../containers/Profile";
import NotFound from "../containers/NotFound";

export default [
  {
    path: "/",
    key: "/",
    component: App,
    routes: [
      {
        path: "/",
        key: "/home",
        exact: true,
        component: Home,
      },
      {
        path: "/profile",
        key: "/profile",
        component: Profile,
      },
      {
        component: NotFound,
      },
    ],
  },
]
  • 客户端 client.js
import routes from "../routes"
import { BrowserRouter } from "react-router-dom"
import { renderRoutes } from "react-router-config"

ReactDom.hydrate(
    <BrowserRouter>{renderRoutes(routes)}</BrowserRouter>
  document.getElementById("root")
)
  • 服务端 server.js
const content = renderToString((
    <StaticRouter context={context} location={req.path}>
    {renderRoutes(routes)}
    </StaticRouter>
))

重定向302和404

  • 在使用<Redirect>重定向时,由于服务端渲染返回给客户端的状态码始终是200
  • 未匹配到路由,进入NotFound组件,给客户端返回的也是成功状态码200

这两个问题需要在服务端拦截处理,返回正确的状态码给客户端。

记得前面给服务端路由传入的context参数:

<StaticRouter context={context} location={req.path}>

当路由重定向时,会给props.staticContext加入{action:"REPLACE"}的信息,以此判断是否重定向:

// render
const content = renderToString(<App />)
// return 重定向到context的url地址
if (context.action === "REPLACE") return res.redirect(302, context.url)

进入NotFound组件,判断是否有props.staticContext对象,有代表在服务端渲染,新增属性给服务端判断:

export default function (props) {
  if (props.staticContext) {
    // 新增 notFound 属性
    props.staticContext.notFound = true;
  }
  return <div>NotFound</div>
}

进入到

const content = renderToString(<App />);
// 存在 notFound 属性,设置状态码
if (context.notFound) res.status(404)

redux与数据注入

首先,服务端渲染的数据从数据服务器获取,客户端获取数据通过服务端中间层再去获取数据层数据。

客户端 ---> 代理服务 ---> 数据接口服务

服务端 ---> 数据接口服务

加入接口代理

客户端通过服务端调用接口数据,需要设置代理,这里用的express框架,所用使用了express-http-proxy:

const proxy = require("express-http-proxy");

app.use(
  "/api",
  // 数据接口地址
  proxy("http://localhost:3001", {
    proxyReqPathResolver: function (req) {
      return `/api${req.url}`;
    },
  })
);

两种请求方式

由于请求方式不同,所以服务端和客户端需要各自维护一套请求方法。

  • 服务端request.js:
import axios from "axios";

export default (req)=>{
    // 服务层请求获取接口数据不会有跨域问题
  return axios.create({
    baseURL: "http://localhost:3001/",
    // 需要带上 cookie
    headers: {
      cookie: req.get("cookie") || "",
    },
  })
}
  • 客户端request.js:
import axios from "axios";

export default axios.create({
    baseURL:"/"
})

创建 store

接着创建store文件夹,我这边的基本目录结构如下:

/-store
    /- actions
    /- reduces
    - action-types.js
    - index.js

为了让接口调用更加方便,这里引入了redux-thunk中间件,并利用withExtraArgument属性绑定了服务端和客户端请求:

import reducers from "./reducers";
import {createStore,applyMiddleware} from 'redux'
import clientRequest from "../client/request";
import serverRequest from "../server/request";
import thunk from "redux-thunk";

// 服务端store,需要加入http的request参数,获取cookie
export function getServerStore(req) {
  return createStore(
    reducers,
    applyMiddleware(thunk.withExtraArgument(serverRequest(req)))
  )
}
export function getClientStore(){
    return createStore(
      reducers,
      initState,
      applyMiddleware(thunk.withExtraArgument(clientRequest))
    );
}

服务端渲染:

import { Provider } from "react-redux"
import { getServerStore } from "../store"

<Provider store={getServerStore(req)}>
    <StaticRouter context={context} location={req.path}>
    {renderRoutes(routes)}
    </StaticRouter>
</Provider>

客户端渲染:

import { Provider } from "react-redux"
import { getClientStore } from "../store"

ReactDom.hydrate(
  <Provider store={getClientStore()}>
      <BrowserRouter>{renderRoutes(routes)}</BrowserRouter>
  </Provider>,
  document.getElementById("root")
)

通过中间件redux-thunk可以在action里面调用接口:

import * as TYPES from "../action-types";

export default {
    getHomeList(){
        // withExtraArgument方法让第三个参数变成axios的请求方法
        return (dispatch,getState,request)=>{
            return request.get("/api/users").then((result) => {
                let list = result.data;
                dispatch({
                  type: TYPES.SET_HOME_LIST,
                  payload: list,
                });
              });
        }
    }
}

数据注入

如果数据通过store调用接口获取,那么服务端渲染前需要先初始化接口数据,等待接口调用完成,数据填充进store.state才去渲染dom。

给需要调用接口的组件新增静态方法loadData,在服务端渲染页面前,判断渲染的组件否有loadData静态方法,有则先执行,等待数据填充。

例如首页调用/api/users获取用户列表:

class Home extends Component {
  static loadData = (store) => {
    return store.dispatch(action.getHomeList());
  }
}

服务端渲染入口修改如下:

import { matchRoutes, renderRoutes } from "react-router-config"
...
async function render(req, res) {
  const context = {}
  const store = getServerStore(req)
  const promiseAll = []
  // matchRoutes判断当前匹配到的路由数组
  matchRoutes(routes, req.path).forEach(({ route: { component = {} } }) => {
    // 如果有 loadData 方法,加载
    if (component.loadData) {
        // 保证返回promise都是true,防止页面出现卡死
      let promise = new Promise((resolve) => {
        return component.loadData(store).then(resolve, resolve)
      })
      promiseAll.push(promise)
    }
  })
  // 等待数据加载完成
  await Promise.all(promiseAll)

  const content = renderToString(
        <Provider store={store}>
            <StaticRouter context={context} location={req.path}>
                {renderRoutes(routes)}
            </StaticRouter>
        </Provider>
  );
  ...
  res.send(`
       <!DOCTYPE html>
        <html>
            <head>
                <title>react-ssr</title>
            </head>
            <script>
              // 将数据绑定到window
              window.context={state:${JSON.stringify(store.getState())}}
            </script>
            <body>
                <div id="root">${content}</div>
                <script data-original="./client.js"></script>
            </body>
        </html>
    `)

等待Promise.all加载完成后,所有需要加载的数据都通过loadData填充进store.state里面,
最后,在渲染页面将store.state的数据获取并绑定到window上。

因为数据已经加载过一遍了,所以在客户端渲染时,把已经初始化好的数据赋值到store.state里面:

export function getClientStore(){
    let initState = window.context.state;
    return createStore(
      reducers,
      initState,
      applyMiddleware(thunk.withExtraArgument(clientRequest))
    );
}

加入css

处理样式可以使用style-loadercss-loader,但是style-loader最终是通过生成style标签插入到document里面的,服务端渲染并没有document,所以也需要分开维护两套webpack.config。

服务端渲染css使用isomorphic-style-loader,webpack配置如下:

  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          "isomorphic-style-loader",
          {
            loader: "css-loader",
            options: {
              modules: true,
            },
          },
        ],
      },
    ],
  } 

客户端配置还是正常配置:

  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          "style-loader",
          {
            loader: "css-loader",
            options: {
              modules: true,
            },
          },
        ],
      },
    ],
  }
这里 css-loader 推荐用@2的版本,最新版本在服务端isomorphic-style-loader取不到样式值

这里有个问题,因为样式css是js生成style标签动态插入到页面,所以服务端渲染好给到客户端的页面,期初是没有样式的,如果js脚本加载慢的话,用户还是能看到没有样式前的页面。

提取css

在服务端渲染前,提取css样式,isomorphic-style-loader也提供了很好的处理方式,这里通过写个高阶函数处理,在加载样式的页面,先提取css代码保存到context里面:

服务端渲染页面,定义context.csses数组保存样式:

const context = { csses:[] }

创建高阶函数 withStyles.js

import React from 'react'

export default function withStyles(RenderComp,styles){
    return function(props){
        if(props.staticContext){
            // 获取css样式保存进csses
            props.staticContext.csses.push(styles._getCss())
        }
        return <RenderComp {...props}></RenderComp>
    }
}

使用:

import React, { Component } from "react";
import { renderRoutes } from "react-router-config";
import action from  "../store/actions/session"
import style from "../style/style.css";
import withStyle from "../withStyles";

class App extends Component {
  static loadData = (store) => {
    return store.dispatch(action.getUserMsg())
  }
  render() {
    return (
        <div className={style.mt}>{renderRoutes(this.props.route.routes)}</div>
    )
  }
}
// 包裹组件
export default withStyle(App,style)

渲染前提取css样式:

const cssStr = context.csses.join("\n")
res.send(`
    <!DOCTYPE html>
    <html>
        <head>
            <title>react-ssr</title>
            <style>${cssStr}</style>
        </head>
    </html>
`)

seo优化

seo优化策略里面,一定会往head里面加入title标签以及两个meta标签(keywordsdescription),
通过react-helmet可以在每个渲染组件头部定义不同的title和meta,非常方便,使用如下:

import { Helmet } from "react-helmet"
...
const helmet=Helmet.renderStatic();
res.send(`
    <!DOCTYPE html>
    <html>
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <meta http-equiv="X-UA-Compatible" content="ie=edge">
            ${helmet.title.toString()}
            ${helmet.meta.toString()}
            <title>react-ssr</title>
            <style>${cssStr}</style>
        </head>
    </html>
`)

在需要插入title或者meta的组件中引入Helmet

import { Helmet } from "react-helmet"

function Home(props){
    return render() {
    return (
      <Fragment>
        <Helmet>
          <title>首页标题</title>
          <meta name="keywords" content="首页关键词" />
          <meta name="description" content="首页描述"></meta>
        </Helmet>
        <div>home</div>
      </Fragment>
    )
}
查看原文

赞 1 收藏 0 评论 0

认证与成就

  • 获得 41 次点赞
  • 获得 2 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 2 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2017-05-01
个人主页被 2.1k 人浏览