JerryC

JerryC 查看完整档案

深圳编辑北京理工大学珠海学院  |  软件工程 编辑腾讯  |  Web Developer 编辑 huang-jerryc.com 编辑
编辑

Peace of mind, Code of enjoy

个人动态

JerryC 发布了文章 · 10月19日

微信小程序登录的前端设计与实现

欢迎来我的博客阅读:「微信小程序登录的前端设计与实现」

一. 前言

对于登录/注册的设计如此精雕细琢的目的,当然是想让这个作为应用的基础能力,有足够的健壮性,避免出现全站性的阻塞。

同时要充分考虑如何解耦和封装,在开展新的小程序的时候,能更快的去复用能力,避免重复采坑。

登录注册这模块,就像个冰山,我们以为它就是「输入账号密码,就完成登录了」,但实际下面还有各种需要考虑的问题。

在此,跟在座的各位分享一下,最近做完一个小程序登录/注册模块之后,沉淀下来的一些设计经验和想法。

二. 业务场景

在用户浏览小程序的过程中,由业务需要,往往需要获取用户的一些基本信息,常见的有:

  1. 微信昵称
  2. 微信手机号

而不同的产品,对于用户的信息要求不尽相同,也会有不一样的授权流程。

第一种,常见于电商系统中,用户购买商品的时候,为了识别用户多平台的账号,往往用手机号去做一个联系,这时候需要用户去授权手机号。

授权手机号

第二种,为了让用户信息得到基本的初始化,往往需要更进一步获取用户信息:如微信昵称,unionId 等,就需要询问用户授权。

授权用户信息

第三种,囊括第一种,第二种。

完整授权流程

三. 概念

秉着沉淀一套通用的小程序登录方案和服务为目标,我们去分析一下业务,得出变量。

在做技术设计之前,讲点必要的废话,对一些概念进行基本调频。

2.1 关于「登录」

登录在英文中是 「login」,对应的还有 「logout」。而登录之前,你需要拥有一个账号,就要 「register」(or sign up)。

话说一开始的产品是没有登录/注册功能的,用的人多了就慢慢有了。出于产品本身的需求,需要对「用户」进行身份识别。

在现实社会中,我们每个人都有一个身份ID:身份证。当我到了16岁的时候,第一次去公安局领身份证的时候,就完成了一次「注册」行为。然后我去网吧上网,身份证刷一下,完成了一次「登录」行为。

那么对于虚拟世界的互联网来说,这个身份证明就是「账号+密码」。

常见的登录/注册方式有:

  1. 账号密码注册

    在互联网的早期,个人邮箱和手机覆盖度小。所以,就需要用户自己想一个账号名,我们注册个QQ号,就是这种形式。

    from 汽车之家

  2. 邮箱地址注册

    千禧年之后,PC互联网时代快速普及,我们都创建了属于自己的个人邮箱。加上QQ也自带邮箱账号。由于邮箱具有个人私密性,且能够进行信息的沟通,因此,大部分网站开始采用邮箱账号作为用户名来进行注册,并且会在注册的过程中要求登录到相应邮箱内查收激活邮件,验证我们对该注册邮箱的所有权。

    from 支付宝

  3. 手机号码注册

    在互联网普及之后,智能手机与移动互联网发展迅猛。手机也成为每个人必不可少的移动设备,同时移动互联网也已经深深融入每个人的现代生活当中。所以,相较于邮箱,目前手机号码与个人的联系更加紧密,而且越来越多的移动应用出现,采用手机号码作为用户名的注册方式也得到了广泛的使用。

    from 知乎

到了 2020 年,微信用户规模达 12 亿。那么,微信账号,起码在中国,已成为新一代互联网世界的「身份标识」。

而对微信小程序而言,天然就能知道当前用户的微信账号ID。微信允许小程序应用,能在用户无感知的情况下,悄无声息的「登录」到我们的小程序应用中去,这个就是我们经常称之为的「静默登录」。

其实微信小程序的登录,跟传统 Web 应用的「单点登录」本质是一样的概念。

  1. 单点登录:在 A 站登录了,C 站和 B 站能实现快速的「静默登录」。
  2. 微信小程序登录:在微信中,登录了微信账号,那么在整个小程序生态中,都可以实现「静默登录」。

由于 Http 本来是无状态的,业界基本对于登录态的一般做法:

  1. cookie-session:常用于浏览器应用中
  2. access token:常用于移动端等非浏览器应用

在微信小程序来说,对于「JS逻辑层」并不是一个浏览器环境,自然没有 Cookie,那么通常会使用 access token 的方式。

2.2 关于「授权」

对于需要更进一步获取用的用户昵称、用户手机号等信息的产品来说。微信出于用户隐私的考虑,需要用户主动同意授权。小程序应用才能获取到这部分信息,这就有了目前流行的小程序「授权用户信息」、「授权手机号」的交互了。

出于不同的用户信息敏感度不同的考虑,微信小程序对于不同的用户信息提供「授权」的方式不尽相同:

  1. 调用具体 API 方式,弹窗授权。

    1. 例如调用 wx.getLocation() 的时候,如果用户未授权,则会弹出地址授权界面。
    2. 如果拒绝了,就不会再次弹窗,wx.getLocation()直接返回失败。
  2. <button open-type="xxx" /> 方式。

    1. 仅支持:用户敏感信息,用户手机号,需要配合后端进行对称加解密,方能拿到数据。
    2. 用户已拒绝,再次点击按钮,仍然会弹窗。
  3. 通过 wx.authorize(),提前询问授权,之后需要获取相关信息的时候不用再次弹出授权。

四. 详细设计

梳理清楚了概念之后,我们模块的划分上,可以拆分为两大块:

  1. 登录:负责与服务端创建起一个会话,这个会话实现静默登录以及相关的容错处理等,模块命名为:Session
  2. 授权:负责与用户交互,获取与更新信息,以及权限的控制处理等,模块命名为:Auth

3.1 登录的实现

3.1.1 静默登录

微信登录

微信官方提供的登录方案,总结为三步:

  1. 前端通过 wx.login() 获取一次性加密凭证 code,交给后端。
  2. 后端把这个 code 传输给微信服务器端,换取用户唯一标识 openId 和授权凭证 session_key。(用于后续服务器端和微信服务器的特殊 API 调用,具体看:微信官方文档-服务端获取开放数据)。
  3. 后端把从微信服务器获取到的用户凭证与自行生成的登录态凭证(token),传输给前端。前端保存起来,下次请求的时候带给后端,就能识别哪个用户。

如果只是实现这个流程的话,挺简单的。

但要实现一个健壮的登录过程,还需要注意更多的边界情况:

  1. 收拢 wx.login() 的调用

    由于 wx.login() 会产生不可预测的副作用,例如会可能导致session_key失效,从而导致后续的授权解密场景中的失败。我们这里可以提供一个像 session.login() 的方法,掌握 wx.login() 控制权,对其做一系列的封装和容错处理。

  2. 调用的时机

    通常我们会在应用启动的时候( app.onLaunch() ),去发起静默登录。但这里会由小程序生命周期设计问题而导致的一个异步问题:加载页面的时候,去调用一个需要登录态的后端 API 的时候,前面异步的静态登录过程有可能还没有完成,从而导致请求失败。

    当然也可以在第一个需要登录态的接口调用的时候以异步阻塞的方式发起登录调用,这个需要结合良好设计的接口层。

    以上讲到的两种场景的详细设计思路下文会讲到。

  3. 并发调用的问题

    在业务场景中,难免会出现多处代码需要触发登录,如果遇到极端情况,这多处代码同时间发起调用。那就会造成短时间多次发起登录过程,尽管之前的请求还没有完成。针对这种情况,我们可以以第一个调用为阻塞,后续调用等待结果,就像精子和卵子结合的过程。

  4. 未过期调用的问题

    如果我们的登录态未过期,完全可以正常使用的,默认情况就不需再去发起登录过程了。这时候我们可以默认情况下先去检查登录态是否可用,不能用,我们再发起请求。然后还可以提供一个类似 session.login({ force: true })的参数去强行发起登录。

3.1.2 静默登录异步状态的处理

1. 应用启动的时候调用

因为大部分情况都需要依赖登录态,我们会很自然而然的想到把这个调用的时机放到应用启动的时候( app.onLaunch() )来调用。

但是由于原生的小程序启动流程中, AppPageComponent 的生命周期钩子函数,都不支持异步阻塞。

那么我们很容易会遇到 app.onLaunch 发起的「登录过程」在 page.onLoad 的时候还没有完成,我们就无法正确去做一些依赖登录态的操作。

针对这种情况,我们设计了一个状态机的工具:status

状态机

基于状态机,我们就可以编写这样的代码:

import { Status } from '@beautywe/plugin-status';

// on app.js
App({
    status: {
       login: new Status('login');
    },

    onLaunch() {
        session
            // 发起静默登录调用
            .login()

            // 把状态机设置为 success
            .then(() => this.status.login.success())
      
            // 把状态机设置为 fail
            .catch(() => this.status.login.fail());
    },
});


// on page.js
Page({
    onLoad() {
      const loginStatus = getApp().status.login;
      
      // must 里面会进行状态的判断,例如登录中就等待,登录成功就直接返回,登录失败抛出等。
      loginStatus().status.login.must(() => {
        // 进行一些需要登录态的操作...
      });
    },
});

2. 在「第一个需要登录态接口」被调用的时候去发起登录

更进一步,我们会发现,需要登录态的更深层次的节点是在发起的「需要登录态的后端 API 」的时候。

那么我们可以在调用「需要登录态的后端 API」的时候再去发起「静默登录」,对于并发的场景,让其他请求等待一下就好了。

fly.js 作为 wx.request() 封装的「网络请求层」,做一个简单的例子:

// 发起请求,并表明该请求是需要登录态的
fly.post('https://...', params, { needLogin: true });

// 在 fly 拦截器中处理逻辑
fly.interceptors.request.use(async (req)=>{

  // 在请求需要登录态的时候
  if (req.needLogin !== false) {

    // ensureLogin 核心逻辑是:判断是否已登录,如否发起登录调用,如果正在登录,则进入队列等待回调。
    await session.ensureLogin();
    
    // 登录成功后,获取 token,通过 headers 传递给后端。
    const token = await session.getToken();
    Object.assign(req.headers, { [AUTH_KEY_NAME]: token });
  }
  
  return req;
});

3.1.3 自定义登录态过期的容错处理

当自定义登录态过期的时候,后端需要返回特定的状态码,例如:AUTH_EXPIREDAUTH_INVALID 等。

前端可以在「网络请求层」去监听所有请求的这个状态码,然后发起刷新登录态,再去重放失败的请求:

// 添加响应拦截器
fly.interceptors.response.use(
    (response) => {
      const code = res.data;
        
      // 登录态过期或失效
      if ( ['AUTH_EXPIRED', 'AUTH_INVALID'].includes(code) ) {
      
        // 刷新登录态
        await session.refreshLogin();
        
        // 然后重新发起请求
        return fly.request(request);
      }
    }
)

那么如果并发的发起多个请求,都返回了登录态失效的状态码,上述代码就会被执行多次。

我们需要对 session.refreshLogin() 做一些特殊的容错处理:

  1. 请求锁:同一时间,只允许一个正在过程中的网络请求。
  2. 等待队列:请求被锁定之后,调用该方法的所有调用,都推入一个队列中,等待网络请求完成之后共用返回结果。
  3. 熔断机制:如果短时间内多次调用,则停止响应一段时间,类似于 TCP 慢启动。

示例代码:

class Session {
  // ....
  
  // 刷新登录保险丝,最多重复 3 次,然后熔断,5s 后恢复
  refreshLoginFuseLine = REFRESH_LOGIN_FUSELINE_DEFAULT;
  refreshLoginFuseLocked = false;
  refreshLoginFuseRestoreTime = 5000;

  // 熔断控制
  refreshLoginFuse(): Promise<void> {
    if (this.refreshLoginFuseLocked) {
      return Promise.reject('刷新登录-保险丝已熔断,请稍后');
    }
    if (this.refreshLoginFuseLine > 0) {
      this.refreshLoginFuseLine = this.refreshLoginFuseLine - 1;
      return Promise.resolve();
    } else {
      this.refreshLoginFuseLocked = true;
      setTimeout(() => {
        this.refreshLoginFuseLocked = false;
        this.refreshLoginFuseLine = REFRESH_LOGIN_FUSELINE_DEFAULT;
        logger.info('刷新登录-保险丝熔断解除');
      }, this.refreshLoginFuseRestoreTime);
      return Promise.reject('刷新登录-保险丝熔断!!');
    }
  }

  // 并发回调队列
  refreshLoginQueueMaxLength = 100;
  refreshLoginQueue: any[] = [];
  refreshLoginLocked = false;

  // 刷新登录态
  refreshLogin(): Promise<void> {
    return Promise.resolve()
    
      // 回调队列 + 熔断 控制
      .then(() => this.refreshLoginFuse())
      .then(() => {
        if (this.refreshLoginLocked) {
          const maxLength = this.refreshLoginQueueMaxLength;
          if (this.refreshLoginQueue.length >= maxLength) {
            return Promise.reject(`refreshLoginQueue 超出容量:${maxLength}`);
          }
          return new Promise((resolve, reject) => {
            this.refreshLoginQueue.push([resolve, reject]);
          });
        }
        this.refreshLoginLocked = true;
      })

      // 通过前置控制之后,发起登录过程
      .then(() => {
        this.clearSession();
        wx.showLoading({ title: '刷新登录态中', mask: true });
        return this.login()
          .then(() => {
            wx.hideLoading();
            wx.showToast({ icon: 'none', title: '登录成功' });
            this.refreshLoginQueue.forEach(([resolve]) => resolve());
            this.refreshLoginLocked = false;
          })
          .catch(err => {
            wx.hideLoading();
            wx.showToast({ icon: 'none', title: '登录失败' });
            this.refreshLoginQueue.forEach(([, reject]) => reject());
            this.refreshLoginLocked = false;
            throw err;
          });
      });

  // ...
}

3.1.4 微信 session_key 过期的容错处理

我们从上面的「静默登录」之后,微信服务器端会下发一个 session_key 给后端,而这个会在需要获取微信开放数据的时候会用到。

微信开放数据

session_key 是有时效性的,以下摘自微信官方描述:

会话密钥 session_key 有效性

开发者如果遇到因为 session_key 不正确而校验签名失败或解密失败,请关注下面几个与 session_key 有关的注意事项。

  1. wx.login 调用时,用户的 session_key 可能会被更新而致使旧 session_key 失效(刷新机制存在最短周期,如果同一个用户短时间内多次调用 wx.login,并非每次调用都导致 session_key 刷新)。开发者应该在明确需要重新登录时才调用 wx.login,及时通过 auth.code2Session 接口更新服务器存储的 session_key。
  2. 微信不会把 session_key 的有效期告知开发者。我们会根据用户使用小程序的行为对 session_key 进行续期。用户越频繁使用小程序,session_key 有效期越长。
  3. 开发者在 session_key 失效时,可以通过重新执行登录流程获取有效的 session_key。使用接口 wx.checkSession可以校验 session_key 是否有效,从而避免小程序反复执行登录流程。
  4. 当开发者在实现自定义登录态时,可以考虑以 session_key 有效期作为自身登录态有效期,也可以实现自定义的时效性策略。

翻译成简单的两句话:

  1. session_key 时效性由微信控制,开发者不可预测。
  2. wx.login 可能会导致 session_key 过期,可以在使用接口之前用 wx.checkSession 检查一下。

而对于第二点,我们通过实验发现,偶发性的在 session_key 已过期的情况下,wx.checkSession 会概率性返回 true

社区也有相关的反馈未得到解决:

所以结论是:wx.checkSession可靠性是不达 100% 的。

基于以上,我们需要对 session_key 的过期做一些容错处理:

  1. 发起需要使用 session_key 的请求前,做一次 wx.checkSession 操作,如果失败了刷新登录态。
  2. 后端使用 session_key 解密开放数据失败之后,返回特定错误码(如:DECRYPT_WX_OPEN_DATA_FAIL),前端刷新登录态。

示例代码:

// 定义检查 session_key 有效性的操作
const ensureSessionKey = async () => {
  const hasSession = await new Promise(resolve => {
    wx.checkSession({
      success: () => resolve(true),
      fail: () => resolve(false),
    });
  });
  
  if (!hasSession) {
    logger.info('sessionKey 已过期,刷新登录态');

    // 接上面提到的刷新登录逻辑
    return session.refreshLogin();
  }

  return Promise.resolve();
}

// 在发起请求的时候,先做一次确保 session_key 最新的操作(以 fly.js 作为网络请求层为例)
const updatePhone = async (params) => {
  await ensureSessionKey();
  const res = await fly.post('https://xxx', params);
}

// 添加响应拦截器, 监听网络请求返回
fly.interceptors.response.use(
    (response) => {
      const code = res.data;
        
      // 登录态过期或失效
      if ( ['DECRYPT_WX_OPEN_DATA_FAIL'].includes(code)) {

        // 刷新登录态
        await session.refreshLogin();
        
        // 由于加密场景的加密数据由用户点击产生,session_key 可能已经更改,需要用户重新点击一遍。
        wx.showToast({ title: '网络出小差了,请稍后重试', icon: 'none' });
      }
    }
)

3.2 授权的实现

3.2.1 组件拆分与设计

在用户信息和手机号获取的方式上,微信是以 <button open-type='xxx' /> 的方式,让用户主动点击授权的。

那么为了让代码更解耦,我们设计这样三个组件:

  1. <user-contaienr getUserInfo="onUserInfoAuth">: 包装点击交互,通过 <slot> 支持点击区域的自定义UI。
  2. <phone-container getPhonenNmber="onPhoneAuth"> : 与 <user-container> 同理。
  3. <auth-flow>: 根据业务需要,组合 <user-container><phone-container> 组合来定义不同的授权流程。

以开头的业务场景的流程为例,它有这样的要求:

  1. 有多个步骤。
  2. 如果中途断掉了,可以从中间接上。
  3. 有些场景中,只要求达到「用户信息授权」,而不需要完成「用户手机号」。

完整授权流程

那么授权的阶段可以分三层:

// 用户登录的阶段
export enum AuthStep {
  // 阶段一:只有登录态,没有用户信息,没有手机号
  ONE = 1,

  // 阶段二:有用户信息,没有手机号
  TWO = 2,

  // 阶段三:有用户信息,有手机号
  THREE = 3,
}

AuthStep 的推进过程是不可逆的,我们可以定义一个 nextStep 函数来封装 AuthStep 更新的逻辑。外部使用的话,只要无脑调用 nextStep 方法,等待回调结果就行。

示例伪代码:

// auth-flow component

Component({
  // ...
  
  data: {
    // 默认情况下,只需要到达阶段二。
    mustAuthStep: AuthStep.TWO
  },
  
  // 允许临时更改组件的需要达到的阶段。
  setMustAuthStep(mustAuthStep: AuthStep) {
    this.setData({ mustAuthStep });
  },
  
  // 根据用户当前的信息,计算用户处在授权的阶段
  getAuthStep() {
    let currAuthStep;
    
    // 没有用户信息,尚在第一步
    if (!session.hasUser() || !session.hasUnionId()) {
      currAuthStep = AuthStepType.ONE;
    }

    // 没有手机号,尚在第二步
    if (!session.hasPhone()) {
      currAuthStep = AuthStepType.TWO;
    }

    // 都有,尚在第三步
    currAuthStep = AuthStepType.THREE;
    return currAuthStep;
  }
  
  // 发起下一步授权,如果都已经完成,就直接返回成功。
  nextStep(e) {
    const { mustAuthStep } = this.data;
    const currAuthStep = this.updateAuthStep();
  
    // 已完成授权
    if (currAuthStep >= mustAuthStep || currAuthStep === AuthStepType.THREE) {
      // 更新全局的授权状态机,广播消息给订阅者。
      return getApp().status.auth.success();
    }

    // 第一步:更新用户信息
    if (currAuthStep === AuthStepType.ONE) {
      // 已有密文信息,更新用户信息
      if (e) session.updateUser(e);

      // 更新到视图层,展示对应UI,等待获取用户信息
      else this.setData({ currAuthStep });
      return;
    }

    // 第二步:更新手机信息
    if (currAuthStep === AuthStepType.TWO) {
      // 已有密文信息,更新手机号
      if (e) this.bindPhone(e);

      // 未有密文信息,弹出获取窗口
      else this.setData({ currAuthStep });
      return;
    }

    console.warn('auth.nextStep 错误', { currAuthStep, mustAuthStep });
  },
  
  // ...
});

那么我们的 <auth-flow> 中就可以根据 currAuthStepmustAuthStep 来去做不同的 UI 展示。需要注意的是使用 <user-container><phone-container> 的时候连接上 nextStep(e) 函数。

示例伪代码:

<view class="auth-flow">

  <!-- 已完成授权 -->
  <block wx:if="{{currAuthStep === mustAuthStep || currAuthStep === AuthStep.THREE}}">
    <view>已完成授权</view>
  </block>

  <!-- 未完成授权,第一步:授权用户信息 -->
  <block wx:elif="{{currAuthStep === AuthStep.ONE}}">
    <user-container bind:getuserinfo="nextStep">
      <view>授权用户信息</view>
    </user-container>
  </block>

  <!-- 未完成授权,第二步:授权手机号 -->
  <block wx:elif="{{currAuthStep === AuthStep.TWO}}">
    <phone-container bind:getphonenumber="nextStep">
      <view>授权手机号</view>
    </phone-container>
  </block>
  
</view>

3.2.2 权限拦截的处理

到这里,我们制作好了用来承载授权流程的组件 <auth-flow> ,那么接下来就是决定要使用它的时机了。

我们梳理需要授权的场景:

  1. 点击某个按钮,例如:购买某个商品。

    对于这种场景,常见的是通过弹窗完成授权,用户可以选择关闭。

    授权模型-弹窗

  2. 浏览某个页面,例如:访问个人中心。

    对于这种场景,我们可以在点击跳转某个页面的时候,进行拦截,弹窗处理。但这样的缺点是,跳转到目标页面的地方可能会很多,每个都拦截,难免会错漏。而且当目标页面作为「小程序落地页面」的时候,就避免不了。

    这时候,我们可以通过重定向到授权页面来完成授权流程,完成之后,再回来。

    授权模型-页面

那么我们定义一个枚举变量:

// 授权的展示形式
export enum AuthDisplayMode {
  // 以弹窗形式
  POPUP = 'button',

  // 以页面形式
  PAGE = 'page',
}

我们可以设计一个 mustAuth 方法,在点击某个按钮,或者页面加载的时候,进行授权控制。

伪代码示例:

class Session {
  // ...
  
  mustAuth({
    mustAuthStep = AuthStepType.TWO, // 需要授权的LEVEL,默认需要获取用户资料
    popupCompName = 'auth-popup',    // 授权弹窗组件的 id
    mode = AuthDisplayMode.POPUP, // 默认以弹窗模式
  } = {}): Promise<void> {
    
    // 如果当前的授权步骤已经达标,则返回成功
    if (this.currentAuthStep() >= mustAuthStep) return Promise.resolve();

    // 尝试获取当前页面的 <auth-popup id="auth-popup" /> 组件实例
    const pages = getCurrentPages();
    const curPage = pages[pages.length - 1];
    const popupComp = curPage.selectComponent(`#${popupCompName}`);

    // 组件不存在或者显示指定页面,跳转到授权页面
    if (!popupComp || mode === AuthDisplayMode.PAGE) {
      const curRoute = curPage.route;

      // 跳转到授权页面,带上当前页面路由,授权完成之后,回到当前页面。
      wx.redirectTo({ url: `authPage?backTo=${encodeURIComponent(curRoute)}` });
      return Promise.resolve();
    }
    
    // 设置授权 LEVEL,然后调用 <auth-popup> 的 nextStep 方法,进行进一步的授权。
    popupComp.setMustAuthStep(mustAuthStep);
    popupComp.nextStep();

    // 等待成功回调或者失败回调
    return new Promise((resolve, reject) => {
      const authStatus = getApp().status.auth;
      authStatus.onceSuccess(resolve);
      authStatus.onceFail(reject);
    });
  }
  
  // ...
}

那么我们就能在按钮点击,或者页面加载的时候进行授权拦截:

Page({
  onLoad() {
    session.mustAuth().then(() => {
      // 开始初始化页面...
    });
  }
  
  onClick(e) {
    session.mustAuth().then(() => {
      // 开始处理回调逻辑...
    });
  }
})

当然,如果项目使用了 TS 的话,或者支持 ES7 Decorator 特性的话,我们可以为 mustAuth 提供一个装饰器版本:

export function mustAuth(option = {}) {
  return function(
    _target,
    _propertyName,
    descriptor,
  ) {
    // 劫持目标方法
    const method = descriptor.value;
    
    // 重写目标方法
    descriptor.value = function(...args: any[]) {
      return session.mustAuth(option).then(() => {
        // 登录完成之后,重放原来方法
        if (method) return method.apply(this, args);
      });
    };
  };
}

那么使用方式就简单一些了:

Page({
  @mustAuth();
  onLoad() {
    // 开始初始化页面...
  }
  
  @mustAuth();
  onClick(e) {
    // 开始处理回调逻辑...
  }
});

3.3. 前后端交互协议整理

作为一套可复用的小程序登录方案,当然需要去定义好前后端的交互协议。

那么整套登录流程下来,需要的接口有这么几个:

登录注册前后端接口协议

  1. 静默登录 silentLogin

    1. 入参:

      1. code: 产自 wx.login()
    2. 出参:

      1. token: 自定义登录态凭证
      2. userInfo: 用户信息
    3. 说明:

      1. 后端利用 code 跟微信客户端换取用户标识,然后注册并登录用户,返回自定义登录态 token 给前端
      2. token 前端会存起来,每个请求都会带上
      3. userInfo 需要包含nicknamephone字段,前端用于计算当前用户的授权阶段。当然这个状态的记录可以放在后端,但是我们认为放在前端,会更加灵活。
  2. 更新用户信息 updateUser

    1. 入参:

      1. nickname: 用户昵称
      2. encrypt: 微信开放数据相关的 iv, encryptedData
      3. 以及其他如性别地址等非必要字段
    2. 出参:

      1. userInfo:更新后的最新用户信息
    3. 说明:

      1. 后端解密微信开放数据,获取隐蔽数据,如:unionId
      2. 后端支持更新包括 nickname等用户基本信息。
      3. 前端会把 userInfo 信息更新到 session 中,用于计算授权阶段。
  3. 更新用户手机号 updatePhone

    1. 入参:

      1. encrypt:微信开放数据相关的 iv, encryptedData
    2. 出参:

      1. userInfo:更新后的最新用户信息
    3. 说明:

      1. 后端解密开放式局,获取手机号,并更新到用户信息中。
      2. 前端会把 userInfo 信息更新到 session 中,用于计算授权阶段。
  4. 解绑手机号 unbindPhone

    1. 入参:-
    2. 出参:-
    3. 说明:后端解绑用户手机号,成功与否,走业务定义的前后端协议。
  5. 登录 logout

    1. 入参:-
    2. 出参:-
    3. 说明:后端主动过期登录态,成功与否,走业务定义的前后端协议。

五. 架构图

最后我们来梳理一下整体的「登录服务」的架构图:

微信小程序登录服务架构图

由「登录服务」和「底层建设」组合提供的通用服务,业务层只需要去根据产品需求,定制授权的流程 <auth-flow> ,就能满足大部分场景了。

六. 总结

本篇文章通过一些常见的登录授权场景来展开来描述细节点。

整理了「登录」、「授权」的概念。

然后分别针对「登录」介绍了一些关键的技术实现:

  1. 静默登录
  2. 静默登录异步状态的处理
  3. 自定义登录态过期的容错处理
  4. 微信 session_key 过期的容错处理

而对于「授权」,会有设计UI部分的逻辑,还需要涉及到组件的拆分:

  1. 组件拆分与设计
  2. 权限拦截的处理

然后,梳理了这套登录授权方案所依赖的后端接口,和给出最简单的参考协议。

最后,站在「秉着沉淀一套通用的小程序登录方案和服务为目标」的角度,梳理了一下架构层面上的分层。

  1. 业务定制层
  2. 登录服务层
  3. 底层建设

七. 参考

  1. fly.js 官网
  2. 微信官方文档-授权
  3. 微信官方文档-服务端获取开放数据
  4. 微信官方社区

    1. 小程序解密手机号,隔一小段时间后,checksession:ok,但是解密失败
    2. wx.checkSession有效,但是解密数据失败
    3. checkSession判断session_key未失效,但是解密手机号失败
查看原文

赞 27 收藏 17 评论 1

JerryC 关注了用户 · 10月10日

百度小程序技术 @smartprogram

关注 75

JerryC 赞了文章 · 10月10日

【走进小程序原理】揭秘组件同层渲染

阅读本文的收获:为什么我的小程序组件不能随着页面滚动?为什么组件层级不对?我该如何解决?

在日常开发中,我们总能在小程序的开发文档里看到种种组件:

基础组件:小程序框架层开发

自定义组件:开发者or小程序官方,基于基础组件进行二次开发

动态库组件:小程序官方开发的、以动态库形式发布的组件,其本质依然是自定义、基础组件

......

综上:就像是盖楼,框架开发的基础组件,是小程序所有组件建筑的地基,我们今天要聊的正是它。

基础组件实现

前置名词解释

NANative App的缩写,是基于智能手机本地操作系统如iOS、Android、WP并使用原生程式编写运行的第三方应用程序,一般开发语言为JAVA、C++、Objective-C、Swift
NA 组件:也称原生组件,是Android、iosNA客户端开发的控件
H5组件:是指HTML5语言编写的web组件
webview:用来在NA代码中展示web页面,有点类似web中的iframeios、Android中分别采取WKWebViewWebView控件实现。

前置特性解释

  1. 小程序前端框架,会将开发者实现的小程序布局转换成标准 HTML 布局;
  2. NA 组件与webview在两个层级(如下图1.1)
  3. 在客户端代码中,后插入的NA组件,层级高于之前的NA组件

框架层的基础组件,是基于H5组件和NA组件实现的。 图片

比如小程序中的 canvas、map、animation-view、textarea、cover-view、cover-image、camera、video、live-player、input 这些都是原生组件

相比于H5组件,NA组件不仅可以提供H5组件无法实现的一些功能,还能提升用户体验上的流畅度,又因为减少了客户端代码与webview通信的流程,降低了通信开销。

简单来说,NA组件功能全、速度快、开销少;然而,命运赠送的礼物,早已在暗中标好了价格——原生组件并不是十全十美,它付出了其他代价。

图1.1

图1.1

由于原生组件脱离在 webview 渲染流程外,因此在使用时有以下限制:

  1. 原生组件的层级是最高的:页面中的其他组件无论设置 z-index 为多少,都无法盖在原生组件上;
  2. 部分 CSS 样式无法应用于原生组件;
  3. 原生组件无法在 scroll-viewswiperpicker-viewmovable-view 中使用:因为如果开发者在可滚动的DOM区域,插入原生组件作为其子节点,由于原生组件是直接插入到webview外部的层级,与DOM之间没有关联,所以不会跟随移动也不会被裁减

这也就解释了,为什么你在使用一些原生组件时,会出现组件不随着页面滚动或是层级永远最高的bug。

.......是不是有点难搞?

解决NA的限制

解决这个问题不是一蹴而就的,它也有自己的历史进程:

图片

cover-imagecover-view,是局部解决方案:由于在客户端中,后插入的原生组件层级高于前面的原生组件,所以把想覆盖原生组件的内容,用一个原生组件包裹后插入,从而hack解决。

但这样做,就像是写css的时候,写了一堆!important,并不是一个优雅的解决方案,后面提到的同层渲染才是终极大杀器。

同层渲染

图片

为了解决原生组件的层级问题,同时尽可能保留 NA 组件的优势,小程序客户端、前端及浏览内核团队一起制定了一套解决方案:由于此方案的控件并非绘制在 NA 贴片层,而是绘制在 WebView 所渲染的页面中,与其他 HTML 控件在同一层级,因此称为「同层渲染」;在支持同层渲染后,原生组件与其它H5组件可以随意叠加,层级的限制将不复存在。

Android 同层渲染原理

前置特性解释

T7:T7内核是百度手机浏览器基于Blink研发的浏览内核
ZeusPlugin:T7浏览器内核的一个插件机制,可用来解析或发送前端、客户端指令,作为两者通信的中枢
swanCore:小程序前端框架

小程序在 Android 端采用 T7 浏览内核作为渲染层,内核提供了 ZeusPlugin 指令系统。

  1. SwanCore 将开发者实现的小程序布局转换成标准 HTML 布局,并对同层渲染的组件增加标识;
  2. T7 浏览内核渲染页面时,识别到标识,则认为此组件为同层组件;
  3. T7 浏览内核根据需求为同层组件扩展方法和属性,供前端 SwanCore 调用;
  4. 扩展的能力部分由浏览内核实现,也可通过小程序客户端的能力实现,根据能力具体内容而定。

图片

图片

ios 同层渲染原理

前置名词

WKWebViewNA组件,用来在NA代码中展示web页面,它在内部采用的是分层方式进行渲染
Compositing Layer:NA合成层,内核一般会将多个webview内的 DOM 节点渲染到一个 Compositing Layer 上,因此合成层与 DOM 节点之间不存在一对一的映射关系
WKChildScrollView:也是NA组件,但 WebKit 内核已经处理了它与其他 DOM 节点之间的层级关系,与 webview内的DOM 节点存在映射关系

前置特性

当把一个webview内的 DOM 节点的 CSS 属性设置为 overflow: scroll (低版本需同时设置 -webkit-overflow-scrolling: touch)之后,NAWKWebView 会为其生成一个对应的 WKChildScrollView

iOS 端同层渲染,也正是基于 WKChildScrollView 实现的,大致流程如下:

  1. 小程序前端,在webview内创建一个 DOM 节点并设置其 CSS 属性为 overflow: hidden-webkit-overflow-scrolling: touch
  2. 前端通知客户端查找到该 DOM 节点对应的原生 WKChildScrollView 组件;
  3. 将原生组件挂载到该 WKChildScrollView 节点上作为其子 View
  4. WebKit 内核已经处理了WKChildScrollView与对应DOM 节点之间的层级关系。 图片 通过上述流程,小程序的NA组件就被插入到 WKChildScrollView 了,也即是在 步骤1 创建的那个 DOM 节点映射的原生 WKChildScrollView 节点。此时,修改这个 DOM 节点的样式属性同样也会应用到原生组件上。因此,同层渲染的原生组件与普通的H5组件表现并无二致。

使用组件的注意事项

1)NA组件中支持同层渲染的情况(同时需要注意的是,同层渲染会存在失败的情况,如果尝试5次之后依旧失败,依旧会采用NA组件的方式)

组件名支持版本
videov3.70.0 起
inputv3.105.0 起
textareav3.140.1起
live-playerv3.140.1 起

2)未支持同层渲染的NA组件或者较低版本,需要注意上文提到的原生组件的使用限制:

  • 原生组件的层级是最高的,所以页面中的其他组件无论设置 z-index 为多少,都无法盖在原生组件上。后插入的原生组件可以覆盖之前的原生组件;
  • 原生组件无法在 scroll-view、swiper、picker-view、movable-view 中使用;
  • 无法对原生组件设置 CSS 动画;
  • 不能在父级节点使用 overflow: hidden 来裁剪原生组件的显示区域

3)如需在NA组件中增加更高层级的组件,可考虑使用cover-image、cover-view

- END -

查看原文

赞 2 收藏 1 评论 0

JerryC 赞了文章 · 7月27日

关于DNS解析

这篇文章在说什么

1、域名的结构
2、DNS解析流程

前奏

在进入正题之前可以先适当的引入IP概念,以便下面的流畅阅读。

1、[什么是IP地址?](https://segmentfault.com/a/1190000022864573)
IP地址相当于网络中的身份唯一认证ID

正题

 DNS:(Domain Name System)域名解析系统
域名解析系统,听着还挺费解的,我们知道当我们浏览器输入网址的时候,输入的是一串域名,例如:www.google.com,但是我们在委托我们的操作系统发送消息时,却不是靠域名来找到对应的服务器,靠的的IP地址(这是TCP/IP协议的要求)。这个时候,所需要做的就是通过域名解析,来拿到我们的IP地址。

域名的结构

域名可以通过.拆分成几个部分,从右到左依次是:顶级域名、二级域名、三级域名...

域名.png

DNS解析流程

所以当我们输入网址,去请求资源的话,那它又是如何办到的呢?

简单来说:DNS解析过程属于应用层协议(不知道应用层也不影响解析流程),当我们生成http报文之后,就会在查找浏览器/host/本地/网关/本地DNS服务器/IPS/根域名服务器等中是否有DNS缓存,如果有的话,优先取缓存数据,否则,会通过主机上运行的DNS客户端(我们的计算机上会有相应的DNS客户端,又称DNS解析器)向DNS服务器发送查询报文,DNS服务器再根据查询消息返回响应内容。

image.png

查询报文
域名、类型(表示域名对应什么类型的记录,类型为MX时,表示域名对应的是邮件服务器,类型为A时,对应的IP地址)、以及Class(Class的值用来识别网络信息,现在互联网没有其他网络,所以永远是IN)。DNS服务器会根据查询消息来查询对应的消息记录。
邮件查询
邮件的记录类型是MX,又称为邮件交换记录。它是通过邮件地址的”@“符号后面的域名,得到对应的邮件服务器。DNS服务器会返回邮件服务器的域名和优先级。(邮件地址有可能对应多个邮件服务器,需要根据优先级来判断哪个服务器优先查询。数值越小越优先。)因为最终也需要得到邮件服务器的IP地址,所以拿到邮件的服务器域名后最终又会解析成IP地址返回客户端。

image.png


演示

光说没用,我们可以来演示一波,当我们查询www.google.com时:
image.png

DNS客户端的请求报文

;; QUESTION SECTION:
;www.google.com.            IN    A

DNS服务器返回的查询结果
只有1个A记录代表,只有一个IP地址。221是缓存时间,代表221s内不用重新查询。

;; ANSWER SECTION:
www.google.com.        221    IN    A    8.7.198.45

NS记录
即域名服务器记录(Name Server),用来指定该域名由那个DNS域名服务器解析。

;; AUTHORITY SECTION:
google.com.        51    IN    NS    ns4.google.com.
google.com.        51    IN    NS    ns3.google.com.
google.com.        51    IN    NS    ns1.google.com.
google.com.        51    IN    NS    ns2.google.com.

DNS域名服务器的IP地址

;; ADDITIONAL SECTION:
ns1.google.com.        266    IN    A    216.239.32.10
ns1.google.com.        197    IN    AAAA    2001:4860:4802:32::a
ns2.google.com.        280    IN    A    216.239.34.10
ns2.google.com.        197    IN    AAAA    2001:4860:4802:34::a
ns3.google.com.        55    IN    A    216.239.36.10
ns3.google.com.        104    IN    AAAA    2001:4860:4802:36::a
ns4.google.com.        299    IN    A    216.239.38.10
ns4.google.com.        92    IN    AAAA    2001:4860:4802:38::a

邮件的DNS查询
可以看到DNS服务器返回了五个服务器域名以及优先级。
image.png

记录类型还有很多种

想了解的可以 => 记录类型
来自维基百科部分截图


当浏览器发起请求

直接上图吧~说太多都没有用~
DNS域名解析流程.png

  • 当我们的浏览器发起http请求时,首先会先查询浏览器是否有DNS缓存
  • 浏览器没有缓存,则会找到计算机的本地hosts文件,是否存在映射关系。
hosts文件地址
Mac:/etc/hosts
Windows 7: C:\\**Windows**\\System32\\drivers\\etc

我们可以看到,下图中有域名对应着IP
就相当于告诉计算机,如果我访问这个域名,那你就去这个ip地址找资源吧~

image.png

  • 如果hosts文件不存在映射关系,那么会去找DNS的本地缓存。
  • 本地没有缓存的话,则会通过我们本地设置的DNS服务器地址,去找本地DNS服务器要资源。一般来说本地DNS服务器都会有一份缓存,如果有的话,就直接将缓存的内容传回去,没有的话,那么它就会去找根服务器。
说到这里,那我们停一下,现在是不是有两个疑问
1、究竟什么是本地服务器呢?
2、如果本地有缓存又要怎么办?
留着最后回答~
我们先来解释图中本地DNS服务器与DNS服务器之间的关系,以及什么是根服务器。

DNS服务器之间的联系
DNS服务器相互之间的联系是:管理下一级域名的服务器会将自己注册到管理上级域名的DNS服务器上。

image.png
所以,当我们从根域名服务器一层层往下找,就可以找到当前域名所在的DNS服务器了。

什么是根域名服务器
前面说了域名的结构,但是在我们的互联网中,还有一个不为人知的地方,叫做根域。它处于一级域名(顶级域名)的上方,根域没有自己的名字(不配有姓名),我们在输入域名时经常省略了它。它是一个点,是的,就是一点”.”,如果要表明根域,那么域名就会写成这样:”www.youzan.com.”没在域名的最后加一个句号。一般都不会写句号。根域名服务器管理的不是所有的域名,而是管理一级域名的服务器所在地址,比如管理着com域名服务器的地址。
很多资料上说,全世界IPv4根域名服务器只有13台。
13台根域名服务器的名字是从A-M。
1个主根服务器在美国,其余为12个辅根服务器,美国(9),英国(1),瑞典(1),日本(1)。
有人是不是想问为什么中国没有?嗯,就是没有。
(因为互联网起源于美国,一开始只有美国有互联网,大部分在美国无可厚非。)
但是中国有IPv4镜像根服务器。
编号相同的镜像根服务器使用同一个IP。
所以,其实上面的说法是不精准的,根域名服务器其实有很多台,但是服务器的IP地址只有13个。

题外话:IPv6根服务器中国有4个,一个主根,三个辅根。 

主根和辅根的区别:主根和辅根的数据是一致的,当有新的域名出现时,会先更新到主根服务器,再复制到辅根服务器。

镜像服务器:相当于镜子里的你,除了不是真正的你,也具有你的特征。就像你的桌面图标生成一个快捷方式的图标一样。


现在我们了解了DNS服务器之间的联系,那么我们回到流程图中:

  • 本地DNS服务器先是去根服务器找域名的ip,根域名服务器没有,给了他com域名服务器的ip。
  • 但是com域名服务器也不知道www.test.com的ip,但是知道test.com在哪台域名服务器上。
  • 最终,找到了www.test.com

QA环节

1、究竟什么是本地服务器呢?
当我们打开网络配置的时候,会看到有一个DNS IP地址,这个IP地址则是我们指向的本地DNS服务器地址。
不同的操作系统设置方式不一样,DNS服务器的地址可以是提前设置好的也可以是自动分配的,MacOS的长这样:image.png

在我们非手动设置的情况下:如果我们的网络是直连的运营商网络,一般而言那我们的本地DNS则是ISP运营商IP地址。
如果我们设置了转发(使用了路由器),那我们的地址极有可能是192.168.1.1(如上图),路由器本身,我们的路由器会将请求转发到上层DNS,也就是ISP运营商DNS服务器。

2、如果本地有缓存又要怎么办?
所以以后如果页面打不开了,可以先清除浏览器或者电脑的DNS缓存试试,看是否是因为本地的缓存导致域名解析错误。

清除DNS缓存:
Mac(10.13.6): sudo dscacheutil -flushcache
Window: ipconfig /flushdns
谷歌浏览器:chrome://net-internals/#events.

3、为什么服务器的IP地址只有13个?

因为DNS查询用的是UDP,而不是TCP。
UDP 实现中能保证正常工作的最大包是 512 字节,所以只能13个根服务器地址。
想要了解更多,请进入[传送门](https://jaminzhang.github.io/dns/The-Reason-of-There-Is-Only-13-DNS-Root-Servers/)

4、IPv4与IPv6的区别

IPv4:由32位二进制数组成
IPv6:可由128位二进制组成
[详文可阅读](https://zhuanlan.zhihu.com/p/50747832)

5、为什么需要域名解析,而不直接是IP?

1、域名好记,给你ip,你可以记几个ip地址哇
2、不同域名可以对应同一个IP
3、服务器IP变了咋办
4、TCP/IP协议的需要
参考资料:
《网络是怎么连接的》
查看原文

赞 6 收藏 0 评论 2

JerryC 赞了文章 · 7月27日

走进AST

前言:AST已经深入的存在我们项目脚手架中,但是我们缺不了解他,本文带领大家一起体验AST,感受一下解决问题另一种方法

什么是AST

在讲之前先简单介绍一下什么AST,抽象语法树(Abstract Syntax Tree)简称 AST,是源代码的抽象语法结构的树状表现形式。
平时很多库都有他的影子:
image.png
例如 babel, es-lint, node-sass, webpack 等等。

OK 让我们看下代码转换成 AST 是什么样子。

const ast = 'tree'

这是一行简单的声明代码,我们看下他转换成AST的样子

image.png

我们发现整个树的根节点是 Program,他有一个子节点 bodybody 是一个数组,数组中还有一个子节点 VariableDeclarationVariableDeclaration中表示const ast = 'tree'这行代码的声明,具体的解析如下:

type: 描述语句的类型,此处是一个变量声明类型
kind: 描述声明类型,类似的值有'var' 'let'
declarations: 声明内容的数组,其中每一项都是一个对象
------------type: 描述语句的类型,此处是一个变量声明类型
------------id: 被声明字段的描述
----------------type: 描述语句的类型,这里是一个标识符
----------------name: 变量的名字
------------init: 变量初始化值的描述
----------------type: 描述语句的类型,这里是一个标识符
----------------name: 变量的值

大体上的结构是这样,body下的每个节点还有一些字段没有给大家说明,例如:位置信息,以及一些没有值的key都做了隐藏,推荐大家可以去 asteplorer这个网站去试试看。

总结一下, AST就是把代码通过编译器变成树形的表达形式。

如何生成AST

如何生成把纯文本的代码变成AST呢?编辑器生成语法树一般分为三个步骤

  • 词法分析
  • 语法分析
  • 生成语法树
  1. 词法分析:也叫做扫描。它读取我们的代码,然后把它们按照预定的规则合并成一个个的标识tokens。同时,它会移除空白符,注释,等。最后,整个代码将被分割进一个tokens列表(或者说一维数组)。

比方说上面的例子 const ast = 'tree',会被分析为const、ast、=、'tree'

const ast = 'tree';
[  
 { type: 'keyword', value: 'const' },  
 { type: 'identifier', value: 'a' },  
 { type: 'punctuator', value: '=' },  
 { type: 'numeric', value: '2' },  
]

当词法分析源代码的时候,它会一个一个字母地读取代码,所以很形象地称之为扫描-scans;当它遇到空格,操作符,或者特殊符号的时候,它会认为一个话已经完成了。

2.语法分析:也称为解析器。它会将词法分析出来的数组转化成树形的表达形式。同时,验证语法,语法如果有错的话,抛出语法错误。

3.生成树:当生成树的时候,解析器会删除一些没必要的标识tokens(比如不完整的括号),因此AST不是100%与源码匹配的,但是已经能让我们知道如何处理了。说个题外话,解析器100%覆盖所有代码结构生成树叫做CST(具体语法树)

能否通过第三方库来生成?

有很多的第三方库可以用来实战操作,可以去asteplorer这个网站去找你喜欢的第三方库,这里不限于javascript,其他的语言也可以在这个网站上找到。
如图:
image.png

关于javascript 的第三方库,这里给大家推荐 babel 的核心库babylon

// yarn add babylon
import * as babylon from 'babylon';

const code = `
    const ast = 'tree'
`

const ast = babylon.parse(code); // ast

如何实践

ok,现在我们已经知道如何把我们的代码变成 AST 了,但是现实中,我们经常会使用到代码的转换,比方说 jsx -> js, es6 -> es5, 是的就是 babel,我们来看看babel是如何转换代码的。

大体上babel转换代码分为三步

1. 通过`babylon`生成`AST`
2. 遍历`AST`同时通过指定的访问器访问需要修改的节点
3. 生成代码

看一个简单的例子一起理解一下
生成AST

import * as babylon from 'babylon';
// 这里也可以使用 import parser from '@babel/parser'; 这个来生成语法树
const code = `
    const ast = 'tree'
    console.log(ast);
`

const ast = babylon.parse(code); // ast

遍历AST同时通过访问器CallExpression来访问console.log(ast)并删除它

import traverse from '@babel/traverse'
import t from '@babel/types';
// 2 遍历

const visitor = {

    CallExpression(path) {
        const { callee } = path.node;
        if (
            t.isMemberExpression(callee) &&
            callee.object.name === 'console' &&
            callee.property.name === 'log'
        ) {
            path.remove();
        }
    },
}

traverse.default(ast, visitor);

生成新代码

import generator from '@babel/generator';
generator.default(ast);
简单的答疑:CallExpression表示这是一个调用,为什么还要做更深入的判断呢,因为直接的函数调用 foo() 这也是一个CallExpression,A.foo()这也是一个CallExpression, 所以要更深入的判断

好的,代码转换完成!值得庆祝。我们可以看到第一步生成AST第三步生成新代码都由babel替我们做了,我们真正操作的地方在于第二步:通过访问器操作需要操作的节点。

由此可见我们开发babel-plugin的时候,也只需要关注visitor这部分就好。

上述代码改为babel-plugin示例:

module.export = function plugin({ types: t}) {
    return {
        visitor: {
            CallExpression(path) {
                const { node } = path;
                if (t.isMemberExpression(node.callee) &&
                node.callee.object.name === 'console' &&
                node.callee.property.name === 'log'
                ) {
                    path.remove();
                }
            },

        },
    };
}

将这个插件加入到你的babel插件列表中,可以看到它真的生效了,一切都是这么简单。so amazing!

结语

开头提到的常用库prettire, eslint, css-loader 等等其实都是先生成AST,然后再操作AST,最后在生成代码。只不过操作AST的过程很复杂,举一反三在项目里,组件库升级,组件批量替换都可以使用这个思路。甚至可以根据业务做一些自己业务方的babel-plugin都行。
感谢您的阅读,有问题可以在评论区交流~

帮助链接
如何开发一个babel-plugin
《AST for JavaScript developers》
查看原文

赞 11 收藏 3 评论 4

JerryC 赞了文章 · 6月16日

IP地址的构成、相同网段、网络掩码

看完这篇文章希望可以解答的问题是:

1、IP地址的构成
2、什么是网络掩码?
3、如何才算是处于相同网段的通信?

看懂所需要的门槛是:二进制换算

计算机之间的通信,可以分为相同网段的通信和不同网段的通信。那什么是相同网段和不同网段呢?不管三七二十一,先画个图,感受一下。

download.png
员工A和B就属于相同网段,A与C、B与C就是不同网段。在图中我们可以看到有IP地址和网关两个玩意儿,他们究竟是什么呢?为什么又能来区分相同网段和不同网段?

在回答之前,先介绍一下什么是IP地址:

IP地址相当于网络中的身份唯一认证ID,跟身份证ID一样是唯一的,唯一不同的是,IP地址是可以变的,只是不管怎么变,都将会是唯一的。Mac地址的性质更加接近于身份证ID,它是设备的唯一ID。

IP地址 = 网络地址 + 主机地址

IP地址目前普遍是IPv4版本,由32位二进制数分成4组,每组1字节Byte(8比特Bit)组成。分别用十进制表示再用圆点隔开,就是现在的172.1.1.10。

什么是网络地址和主机地址?图中172.1.1.10/24的24又指的是什么?
说到这里不得不解释一下什么是子网掩码(又称网络掩码)

24指的是子网掩码的长度,用子网掩码来表示,就是:255.255.255.0。它的作用主要是用来区分网络地址和主机地址。

上面我们说了,员工A和B就属于相同网段。而归根究底是因为他们有相同的网络号,偏偏子网掩码又是用来告诉我们他们是真的有着相同的网络号的。

255.255.255.0用二进制表示,则是:

11111111.11111111.11111111.00000000

172.1.1.10用二进制表示,则是:

10101100.00000001.00000001.00001010

连续24个1,也就是172.1.1.10/24中24的由来。

通过按位与最终得到网段号:

10101100.00000001.00000001.00000000

按位与/& : 1 & 1 => 1 、 1 & 0 => 0 、 0 & 0 => 0
download (1).png

所以172.1.1.10中剩下的10(00001010)即是主机号,172.1.1是网段号,那回到上面的员工A、B、C中:
       员工A(172.1.1.10/24)的网段号:172.1.1
       员工B(172.1.1.11/24)的网段号:172.1.1
       员工C(172.1.2.10/24)的网段号:172.1.2
       显然A、B在同一个网段里

是不是看上去很容易了,那我们学以致用,现在有一个IP地址:172.1.1.10/25,请问,这里的网络位、主机位是多少?主机数是多少?网络地址和广播地址是多少?网络掩码是多少?

解题步骤:
1、首先我们将IP地址转为32位二进制:

10101100.00000001.00000001.00001010

2、从地址中知道子网掩码的长度是25,总长为32Bit,那我们可以写上25个连续的1,剩下的补上0,得到:

11111111.11111111.11111111.10000000 

转为10进制得到:

网络掩码: 255.255.255.128

按位与操作后,可以拿到网络位:

10101100.00000001.00000001.1xxxxxxx

3、那网络地址和广播地址是什么呢,我们将上面的7个x,改为0,得到的就是网络地址(网络号),全部改为1,得到的就是广播地址。所以:

 网络地址:10101100.00000001.00000001.10000000
 十进制:172.1.1.128
 广播地址: 10101100.00000001.00000001.11111111
 十进制:172.1.1.255

4、那么我们的主机位有多少呢?

172.1.1.128 ~172.1.1.255 之间(抛开网络地址和广播地址)一共可以有126个主机位。

当然这样算太累了,用一个比较简便的算法,IP地址总长 32 - 子网掩码长度 25 = 主机位 7,那么根据排列组合主机位 = 2 ^ 7 - 2 = 126,减2是减去广播地址以及网络地址。

算完上面的题目,是不是感觉清晰了很多,那问题又来了?255.255.255.198这个掩码又是不是合法呢?

我们上面的掩码长度,都是连续的1,可 255.255.255.198转为二进制是:11111111.11111111.11111111.11000110

不是连续的1了,很多人认为,这样的子网掩码是不合法的。这是错误的理解,IP协议中给子网掩码提供了一定得灵活性,允许子网掩码中的0和1不连续,但是这样的子网掩码给分配主机以及找到相同网段都造成了一定的难度。市面上也只有极少路由器支持在子网中这样使用。所以实际应用中大多都是采用上述方式。

当我们的企业、公司去申请一个IP地址时,实际上拿到的是网络号,通过网络的性质以及规模,由自己的企业去自行分配主机号。

当然,网络号自然是要划分三六九等的,因为网络的规模差异比较大、而我们的IP资源有限,根据网络号和主机地址来分,主要分为A、B、C三类和特殊地址D、E(可以粗略了解,传送门:https://blog.51cto.com/huchina/2159073

至此,文章开头的问题,应该是有所解答了。
掘金同步发布

查看原文

赞 3 收藏 1 评论 0

JerryC 赞了文章 · 5月27日

深度:从零编写一个微前端框架

写在开头:

手写框架体系文章,缺手写vue和微前端框架文章,今日补上微前端框架,觉得写得不错,记得点个关注+在看,转发更好


对源码有兴趣的,可以看我之前的系列手写源码文章

微前端框架是怎么导入加载子应用的  【3000字精读】

原创:带你从零看清Node源码createServer和负载均衡整个过程

原创:从零实现一个简单版React (附源码)

精读:10个案例让你彻底理解React hooks的渲染逻辑

原创:如何自己实现一个简单的webpack构建工具 【附源码】

从零解析webRTC.io Server端源码


正式开始:

对于微前端,最近好像很火,之前我公众号也发过比较多微前端框架文章

深度:微前端在企业级应用中的实践  (1万字,华为)

万字解析微前端、微前端框架qiankun以及源码

那么现在我们需要手写一个微前端框架,首先得让大家知道什么是微前端,现在微前端模式分很多种,但是大都是一个基座+多个子应用模式,根据子应用注册的规则,去展示子应用。

这是目前的微前端框架基座加载模式的原理,基于single-spa封装了一层,我看有不少公司是用Vue做加载器(有天然的keep-alive),还有用angular和web components技术融合的


首先项目基座搭建,这里使用parcel

mkdir pangu 
yarn init 
//输入一系列信息
yarn add parcel@next

然后新建一个index.html文件,作为基座


<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    
</body>
</html>

新建一个index.js文件,作为基座加载配置文件

新建src文件夹,作为pangu框架的源码文件夹,

新建example案例文件夹

现在项目结构长这样


既然是手写,就不依赖其他任何第三方库

我们首先需要重写hashchange popstate这两个事件,因为微前端的基座,需要监听这两个事件根据注册规则去加载不同的子应用,而且它的实现必须在React、vue子应用路由组件切换之前,单页面的路由源码原理实现,其实也是靠这两个事件实现,之前我写过一篇单页面实现原理的文章,不熟悉的可以去看看

https://segmentfault.com/a/1190000019936510

const HIJACK_EVENTS_NAME = /^(hashchange|popstate)$/i;
const EVENTS_POOL = {
  hashchange: [],
  popstate: [],
};

window.addEventListener('hashchange', loadApps);
window.addEventListener('popstate', loadApps);

const originalAddEventListener = window.addEventListener;
const originalRemoveEventListener = window.removeEventListener;
window.addEventListener = function (eventName, handler) {
  if (
    eventName &&
    HIJACK_EVENTS_NAME.test(eventName) &&
    typeof handler === 'function'
  ) {
    EVENTS_POOL[eventName].indexOf(handler) === -1 &&
      EVENTS_POOL[eventName].push(handler);
  }
  return originalAddEventListener.apply(this, arguments);
};
window.removeEventListener = function (eventName, handler) {
  if (eventName && HIJACK_EVENTS_NAME.test(eventName)) {
    let eventsList = EVENTS_POOL[eventName];
    eventsList.indexOf(handler) > -1 &&
      (EVENTS_POOL[eventName] = eventsList.filter((fn) => fn !== handler));
  }
  return originalRemoveEventListener.apply(this, arguments);
};

function mockPopStateEvent(state) {
  return new PopStateEvent('popstate', { state });
}

// 拦截history的方法,因为pushState和replaceState方法并不会触发onpopstate事件,所以我们即便在onpopstate时执行了reroute方法,也要在这里执行下reroute方法。
const originalPushState = window.history.pushState;
const originalReplaceState = window.history.replaceState;
window.history.pushState = function (state, title, url) {
  let result = originalPushState.apply(this, arguments);
  reroute(mockPopStateEvent(state));
  return result;
};
window.history.replaceState = function (state, title, url) {
  let result = originalReplaceState.apply(this, arguments);
  reroute(mockPopStateEvent(state));
  return result;
};

// 再执行完load、mount、unmout操作后,执行此函数,就可以保证微前端的逻辑总是第一个执行。然后App中的Vue或React相关Router就可以收到Location的事件了。
export function callCapturedEvents(eventArgs) {
  if (!eventArgs) {
    return;
  }
  if (!Array.isArray(eventArgs)) {
    eventArgs = [eventArgs];
  }
  let name = eventArgs[0].type;
  if (!HIJACK_EVENTS_NAME.test(name)) {
    return;
  }
  EVENTS_POOL[name].forEach((handler) => handler.apply(window, eventArgs));
}

上面代码很简单,创建两个队列,使用数组实现


const EVENTS_POOL = {
  hashchange: [],
  popstate: [],
};

如果检测到是hashchange popstate两种事件,而且它们对应的回调函数不存在队列中时候,那么就放入队列中。(相当于redux中间件原理)

然后每次监听到路由变化,调用reroute函数:

function reroute() {
  invoke([], arguments);
}

这样每次路由切换,最先知道变化的是基座,等基座同步执行完(阻塞)后,就可以由子应用的vue-Rourer或者react-router-dom等库去接管实现单页面逻辑了。


那,路由变化,怎么加载子应用呢?

像一些微前端框架会用import-html之类的这些库,我们还是手写吧

逻辑大概是这样,一共四个端口,nginx反向代理命中基座服务器监听的端口(用户必须首先访问到根据域名),然后去不同子应用下的服务器拉取静态资源然后加载。


提示:所有子应用加载后,只是在基座的一个div标签中加载,实现原理跟ReactDom.render()这个源码一样,可参考我之前的文章

原创:从零实现一个简单版React (附源码)


那么我们先编写一个registrApp方法,接受一个entry参数,然后去根据url变化加载子应用(传入的第二个参数activeRule


/**
 *
 * @param {string} entry
 * @param {string} function
 */
const Apps = [] //子应用队列
function registryApp(entry,activeRule) {
    Apps.push({
        entry,
        activeRule
    })
}

注册完了之后,就要找到需要加载的app

export async function loadApp() {
  const shouldMountApp = Apps.filter(shouldBeActive);
  console.log(shouldMountApp, 'shouldMountApp');
  //   const res = await axios.get(shouldMountApp.entry);
  fetch(shouldMountApp.entry)
    .then(function (response) {
      return response.json();
    })
    .then(function (myJson) {
      console.log(myJson, 'myJson');
    });
}

shouldBeActive根据传入的规则去判断是否需要此时挂载:

export function shouldBeActive(app){
    return app.activeRule(window.location)
}

此时的res数据,就是我们通过get请求获取到的子应用相关数据,现在我们新增subapp1和subapp2文件夹,模拟部署的子应用,我们把它用静态资源服务器跑起来

subapp1.js作为subapp1的静态资源服务器

const express = require('express');

subapp2.js作为subapp2的静态资源服务器


const express = require('express');
const app = express();
const { resolve } = require('path');
app.use(express.static(resolve(__dirname, '../subapp1')));

app.listen(8889, (err) => {
  !err && console.log('8889端口成功');
});

现在文件目录长这样:

基座index.html运行在1234端口,subapp1部署在8889端口,subapp2部署在8890端口,这样我们从基座去拉取资源时候,就会跨域,所以静态资源服务器、webpack热更新服务器等服务器,都要加上cors头,允许跨域。


const express = require('express');
const app = express();
const { resolve } = require('path');
//设置跨域访问
app.all('*', function (req, res, next) {
  res.header('Access-Control-Allow-Origin', '*');
  res.header('Access-Control-Allow-Headers', 'X-Requested-With');
  res.header('Access-Control-Allow-Methods', 'PUT,POST,GET,DELETE,OPTIONS');
  res.header('X-Powered-By', ' 3.2.1');
  res.header('Content-Type', 'application/json;charset=utf-8');
  next();
});
app.use(express.static(resolve(__dirname, '../subapp1')));

app.listen(8889, (err) => {
  !err && console.log('8889端口成功');
});

⚠️:如果是dev模式,记得在webpack的热更新服务器中配置允许跨域,如果你对webpack不是很熟悉,可以看我之前的文章:

万字硬核     从零实现webpack热更新HMR

原创:如何自己实现一个简单的webpack构建工具 【附源码】


这里我使用nodemon启用静态资源服务器,简单为主,如果你没有下载,可以:

npm i nodemon -g 
或
yarn add nodemon global 

这样我们先访问下8889,8890端口,看是否能访问到。

访问8889和8890都可以访问到对应的资源,成功


正式开启启用我们的微前端框架pangu.封装start方法,启用需要挂载的APP。


export function start(){
    loadApp()
}

注册子应用subapp1,subapp2,并且手动启用微前端


import { registryApp, start } from './src/index';
registryApp('localhost:8889', (location) => location.pathname === '/subapp1');
registryApp('localhost:8890', (location) => location.pathname === '/subapp2');
start()

修改index.html文件:


<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <div>
        <h1>基座</h1>
        <div class="subapp">
            <div>
                <a href="/subapp1">子应用1</a>
            </div>
            <div>
                <a href="/subapp2">子应用2</a>
            </div>
        </div>
        <div id="subApp"></div>
    </div>
</body>
<script data-original="./index.js"></script>

</html>

ok,运行代码,发现挂了,为什么会挂呢?因为那边返回的是html文件,我这里用的fetch请求,JSON解析不了

那么我们去看看别人的微前端和第三方库的源码吧,例如import-html-entry这个库

由于之前我解析过qiankun这个微前端框架源码,我这里就不做过度讲解,它们是对fetch做了一个text()。


export async function loadApp() {
  const shouldMountApp = Apps.filter(shouldBeActive);
  console.log(shouldMountApp, 'shouldMountApp');
  //   const res = await axios.get(shouldMountApp.entry);
  fetch(shouldMountApp.entry)
    .then(function (response) {
      return response.text();
    })
    .then(function (myJson) {
      console.log(myJson, 'myJson');
    });
}

然后我们已经可以得到拉取回来的html文件了(此时是一个字符串)

由于现实的项目,一般这个html文件会包含js和css的引入标签,也就是我们目前的单页面项目,类似下面这样:

于是我们需要把脚本、样式、html文件分离出来。用一个对象存储

本想照搬某个微前端框架源码的,但是觉得它写得也就那样,今天又主要讲原理,还是自己写一个能跑的把,毕竟html的文件都回来了,数据处理也不难

export async function loadApp() {
  const shouldMountApp = Apps.filter(shouldBeActive);
  console.log(shouldMountApp, 'shouldMountApp');
  //   const res = await axios.get(shouldMountApp.entry);
  fetch(shouldMountApp[0].entry)
    .then(function (response) {
      return response.text();
    })
    .then(function (text) {
      const dom = document.createElement('div');
      dom.innerHTML = text;
      console.log(dom, 'dom');
    });
}

先改造下,打印下DOM

发现已经能拿到dom节点了,那么我先处理下,让它展示在基座中


export async function loadApp() {
  const shouldMountApp = Apps.filter(shouldBeActive);
  console.log(shouldMountApp, 'shouldMountApp');
  //   const res = await axios.get(shouldMountApp.entry);
  fetch(shouldMountApp[0].entry)
    .then(function (response) {
      return response.text();
    })
    .then(function (text) {
      const dom = document.createElement('div');
      dom.innerHTML = text;
      const content = dom.querySelector('h1');
      const subapp = document.querySelector('#subApp-content');
      subapp && subapp.appendChild(content);
    });
}

此时,我们已经可以加载不同的子应用了。

乞丐版的微前端框架就完成了,后面会逐步完善所有功能,向主流的微前端框架靠拢,并且完美支持IE11.记住它叫:pangu

推荐阅读之前的手写ws协议:

深度:手写一个WebSocket协议    [7000字]

最后

  • 欢迎加我微信(CALASFxiaotan),拉你进技术群,长期交流学习...
  • 欢迎关注「前端巅峰」,认真学前端,做个有专业的技术人...

点个赞支持我吧,转发就更好了

查看原文

赞 60 收藏 42 评论 3

JerryC 关注了专栏 · 5月27日

前端巅峰

注重前端性能优化和前沿技术,重型跨平台开发,即时通讯技术等。 欢迎关注微信公众号:前端巅峰

关注 16735

JerryC 关注了用户 · 2019-12-25

Henry @xujing_1986

关注 2

JerryC 关注了专栏 · 2019-12-25

燃烧吧,Tester

有关软件测试技术的交流

关注 5

JerryC 赞了文章 · 2019-09-30

React Fiber 渐进式遍历详解

欢迎关注我的公众号睿Talk,获取我最新的文章:
clipboard.png

一、前言

之前写的一篇文章,React Fiber 原理介绍,介绍了 React Fiber 的实现原理,其中的关键是使用Fiber链的数据结构,将递归的Stack Reconciler改写为循环的Fiber Reconciler。今天将手写一个 demo,详细讲解遍历Fiber链的实现方式。

二、Stack Reconciler

假设有以下组件树:

clipboard.png

对应的 JS 代码如下:

const a1 = {name: 'a1'};
const b1 = {name: 'b1'};
const b2 = {name: 'b2'};
const b3 = {name: 'b3'};
const c1 = {name: 'c1'};
const c2 = {name: 'c2'};
const d1 = {name: 'd1'};
const d2 = {name: 'd2'};

a1.render = () => [b1, b2, b3];
b1.render = () => [];
b2.render = () => [c1];
b3.render = () => [c2];
c1.render = () => [d1, d2];
c2.render = () => [];
d1.render = () => [];
d2.render = () => [];

使用Stack Reconciler递归的方式来遍历组件树,大概是这个样子:

function doWork(o) {
    console.log(o.name);
}

function walk(instance) {
    doWork(instance);
    
    const children = instance.render();
    children.forEach(walk);
}

walk(a1);

// 输出结果:a1, b1, b2, c1, d1, d2, b3, c2

二、Fiber Reconciler

下面我们用 Fiber 的数据结构来改写遍历过程。首先定义数据结构,然后在遍历的过程中通过link方法创建节点间的关系:

// 定义 Fiber 数据结构
class Node {
    constructor(instance) {
        this.instance = instance;
        this.child = null;
        this.sibling = null;
        this.return = null;
    }
}

// 创建关系链
function link(parent, children) {
    if (children === null) children = [];

    // child 指向第一个子元素
    parent.child = children.reduceRight((previous, current) => {
        const node = new Node(current);
        node.return = parent;
        // sibling 指向前面处理的元素
        node.sibling = previous;
        return node;
    }, null);

    return parent.child;
}

遍历完成后会得出如下的关系链:

clipboard.png

下面来详细看下遍历的过程。还是沿用之前的walkdoWork方法名:

function doWork(node) {
    console.log(node.instance.name);
    
    // 创建关系链
    const children = node.instance.render();
    return link(node, children);
}

function walk() {
    while (true) {
        let child = doWork(node);

        if (child) {
            node = child;
            continue;
        }

        if (node === root) {
            return;
        }

        while (!node.sibling) {
            if (!node.return || node.return === root) {
                return;
            }

            node = node.return;
        }

        node = node.sibling;
    }
}

const hostNode = new Node(a1);

const root = hostNode;
let node = root;

walk();

// 输出结果:a1, b1, b2, c1, d1, d2, b3, c2

上面就是递归改循环的代码了。可以看到循环的结束条件是当前处理的节点等于根节点。在循环开始的时候,以深度优先一层一层往下递进。当没有子节点和兄弟节点的时候,当前节点会往上层节点回溯,直至根节点为止。

下面再来看看怎么结合requestIdleCallback API,实现渐进式遍历。由于完成这个遍历所需时间实在太短,因此每处理 3 个节点,我们sleep 1 秒,从而达到退出当前requestIdleCallback的目的,然后再创建一个新的回调任务:

function sleep(n) {
    const start = +new Date();
    while(true) if(+new Date() - start > n) break;
}

function walk(deadline) {
    let i = 1;

    while (deadline.timeRemaining() > 0 || deadline.didTimeout) {
        console.log(deadline.timeRemaining(), deadline.didTimeout);

        let child = doWork(node);

        if (i > 2) {
            sleep(1000);
        }
        i++;

        if (child) {
            node = child;
            continue;
        }

        if (node === root) {
            console.log('================ Task End ===============');
            return;
        }

        while (!node.sibling) {
            if (!node.return || node.return === root) {
                console.log('================ Task End ===============');
                return;
            }

            node = node.return;
        }

        node = node.sibling;
    }

    console.log('================ Task End ===============');

    requestIdleCallback(walk);
}

requestIdleCallback(walk);

// 输出结果:
15.845 false
a1
15.14 false
b1
14.770000000000001 false
b2
================ Task End ===============
15.290000000000001 false
c1
14.825000000000001 false
d1
14.485000000000001 false
d2
================ Task End ===============
14.96 false
b3
14.475000000000001 false
c2
================ Task End ===============

三、总结

本文通过一个 demo,讲解了如何利用React Fiber的数据结构,递归改循环,实现组件树的渐进式遍历。

查看原文

赞 17 收藏 10 评论 1

JerryC 赞了文章 · 2019-09-30

React Hooks 解析(下):进阶

欢迎关注我的公众号睿Talk,获取我最新的文章:
clipboard.png

一、前言

React Hooks 是从 v16.8 引入的又一开创性的新特性。第一次了解这项特性的时候,真的有一种豁然开朗,发现新大陆的感觉。我深深的为 React 团队天马行空的创造力和精益求精的钻研精神所折服。本文除了介绍具体的用法外,还会分析背后的逻辑和使用时候的注意事项,力求做到知其然也知其所以然。

这个系列分上下两篇,这里是上篇的传送门:
React Hooks 解析(上):基础

二、useLayoutEffect

useLayoutEffect的用法跟useEffect的用法是完全一样的,都可以执行副作用和清理操作。它们之间唯一的区别就是执行的时机。

useEffect不会阻塞浏览器的绘制任务,它在页面更新后才会执行。

useLayoutEffectcomponentDidMountcomponentDidUpdate的执行时机一样,会阻塞页面的渲染。如果在里面执行耗时任务的话,页面就会卡顿。

在绝大多数情况下,useEffectHook 是更好的选择。唯一例外的就是需要根据新的 UI 来进行 DOM 操作的场景。useLayoutEffect会保证在页面渲染前执行,也就是说页面渲染出来的是最终的效果。如果使用useEffect,页面很可能因为渲染了 2 次而出现抖动。

三、useContext

useContext可以很方便的去订阅 context 的改变,并在合适的时候重新渲染组件。我们先来熟悉下标准的 context API 用法:

const ThemeContext = React.createContext('light');

class App extends React.Component {
  render() {
    return (
      <ThemeContext.Provider value="dark">
        <Toolbar />
      </ThemeContext.Provider>
    );
  }
}

// 中间层组件
function Toolbar(props) {
  return (
    <div>
      <ThemedButton />
    </div>
  );
}

class ThemedButton extends React.Component {
  // 通过定义静态属性 contextType 来订阅
  static contextType = ThemeContext;
  render() {
    return <Button theme={this.context} />;
  }
}

除了定义静态属性的方式,还有另外一种针对Function Component的订阅方式:

function ThemedButton() {
    // 通过定义 Consumer 来订阅
    return (
        <ThemeContext.Consumer>
          {value => <Button theme={value} />}
        </ThemeContext.Consumer>
    );
}

使用useContext来订阅,代码会是这个样子,没有额外的层级和奇怪的模式:

function ThemedButton() {
  const value = useContext(NumberContext);
  return <Button theme={value} />;
}

在需要订阅多个 context 的时候,就更能体现出useContext的优势。传统的实现方式:

function HeaderBar() {
  return (
    <CurrentUser.Consumer>
      {user =>
        <Notifications.Consumer>
          {notifications =>
            <header>
              Welcome back, {user.name}!
              You have {notifications.length} notifications.
            </header>
          }
      }
    </CurrentUser.Consumer>
  );
}

useContext的实现方式更加简洁直观:

function HeaderBar() {
  const user = useContext(CurrentUser);
  const notifications = useContext(Notifications);

  return (
    <header>
      Welcome back, {user.name}!
      You have {notifications.length} notifications.
    </header>
  );
}

四、useReducer

useReducer的用法跟 Redux 非常相似,当 state 的计算逻辑比较复杂又或者需要根据以前的值来计算时,使用这个 Hook 比useState会更好。下面是一个例子:

function init(initialCount) {
  return {count: initialCount};
}

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {count: state.count + 1};
    case 'decrement':
      return {count: state.count - 1};
    case 'reset':
      return init(action.payload);
    default:
      throw new Error();
  }
}

function Counter({initialCount}) {
  const [state, dispatch] = useReducer(reducer, initialCount, init);
  return (
    <>
      Count: {state.count}
      <button
        onClick={() => dispatch({type: 'reset', payload: initialCount})}>
        Reset
      </button>
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
    </>
  );
}

结合 context API,我们可以模拟 Redux 的操作了,这对组件层级很深的场景特别有用,不需要一层一层的把 state 和 callback 往下传:

const TodosDispatch = React.createContext(null);
const TodosState = React.createContext(null);

function TodosApp() {
  const [todos, dispatch] = useReducer(todosReducer);

  return (
    <TodosDispatch.Provider value={dispatch}>
      <TodosState.Provider value={todos}>
        <DeepTree todos={todos} />
      </TodosState.Provider>
    </TodosDispatch.Provider>
  );
}

function DeepChild(props) {
  const dispatch = useContext(TodosDispatch);
  const todos = useContext(TodosState);

  function handleClick() {
    dispatch({ type: 'add', text: 'hello' });
  }

  return (
    <>
      {todos}
      <button onClick={handleClick}>Add todo</button>
    </>
  );
}

五、useCallback / useMemo / React.memo

useCallbackuseMemo设计的初衷是用来做性能优化的。在Class Component中考虑以下的场景:

class Foo extends Component {
  handleClick() {
    console.log('Click happened');
  }
  render() {
    return <Button onClick={() => this.handleClick()}>Click Me</Button>;
  }
}

传给 Button 的 onClick 方法每次都是重新创建的,这会导致每次 Foo render 的时候,Button 也跟着 render。优化方法有 2 种,箭头函数和 bind。下面以 bind 为例子:

class Foo extends Component {
  constructor(props) {
    super(props);
    this.handleClick = this.handleClick.bind(this);
  }
  handleClick() {
    console.log('Click happened');
  }
  render() {
    return <Button onClick={this.handleClick}>Click Me</Button>;
  }
}

同样的,Function Component也有这个问题:

function Foo() {
  const [count, setCount] = useState(0);

  const handleClick() {
    console.log(`Click happened with dependency: ${count}`)
  }
  return <Button onClick={handleClick}>Click Me</Button>;
}

而 React 给出的方案是useCallback Hook。在依赖不变的情况下 (在我们的例子中是 count ),它会返回相同的引用,避免子组件进行无意义的重复渲染:

function Foo() {
  const [count, setCount] = useState(0);

  const memoizedHandleClick = useCallback(
    () => console.log(`Click happened with dependency: ${count}`), [count],
  ); 
  return <Button onClick={memoizedHandleClick}>Click Me</Button>;
}

useCallback缓存的是方法的引用,而useMemo缓存的则是方法的返回值。使用场景是减少不必要的子组件渲染:

function Parent({ a, b }) {
  // 当 a 改变时才会重新渲染
  const child1 = useMemo(() => <Child1 a={a} />, [a]);
  // 当 b 改变时才会重新渲染
  const child2 = useMemo(() => <Child2 b={b} />, [b]);
  return (
    <>
      {child1}
      {child2}
    </>
  )
}

如果想实现Class ComponentshouldComponentUpdate方法,可以使用React.memo方法,区别是它只能比较 props,不会比较 state:

const Parent = React.memo(({ a, b }) => {
  // 当 a 改变时才会重新渲染
  const child1 = useMemo(() => <Child1 a={a} />, [a]);
  // 当 b 改变时才会重新渲染
  const child2 = useMemo(() => <Child2 b={b} />, [b]);
  return (
    <>
      {child1}
      {child2}
    </>
  )
});

六、useRef

Class Component获取 ref 的方式如下:

class MyComponent extends React.Component {
  constructor(props) {
    super(props);
    this.myRef = React.createRef();
  }
  
  componentDidMount() {
    this.myRef.current.focus();
  }  

  render() {
    return <input ref={this.myRef} type="text" />;
  }
}

Hooks 的实现方式如下:

function() {
  const myRef = useRef(null);

  useEffect(() => {
    myRef.current.focus();
  }, [])
  
  return <input ref={myRef} type="text" />;
}

useRef返回一个普通 JS 对象,可以将任意数据存到current属性里面,就像使用实例化对象的this一样。另外一个使用场景是获取 previous props 或 previous state:

function Counter() {
  const [count, setCount] = useState(0);

  const prevCountRef = useRef();

  useEffect(() => {
    prevCountRef.current = count;
  });
  const prevCount = prevCountRef.current;

  return <h1>Now: {count}, before: {prevCount}</h1>;
}

七、自定义 Hooks

还记得我们上一篇提到的 React 存在的问题吗?其中一点是:

带组件状态的逻辑很难重用

通过自定义 Hooks 就能解决这一难题。

继续以上一篇文章中订阅朋友状态的例子:

import React, { useState, useEffect } from 'react';

function FriendStatus(props) {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }

    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

  if (isOnline === null) {
    return 'Loading...';
  }
  return isOnline ? 'Online' : 'Offline';
}

假设现在我有另一个组件有类似的逻辑,当朋友上线的时候展示为绿色。简单的复制粘贴虽然可以实现需求,但太不优雅:

import React, { useState, useEffect } from 'react';

function FriendListItem(props) {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }

    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

  return (
    <li style={{ color: isOnline ? 'green' : 'black' }}>
      {props.friend.name}
    </li>
  );
}

这时我们就可以自定义一个 Hook 来封装订阅的逻辑:

import React, { useState, useEffect } from 'react';

function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }

    ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
    };
  });

  return isOnline;
}

自定义 Hook 的命名有讲究,必须以use开头,在里面可以调用其它的 Hook。入参和返回值都可以根据需要自定义,没有特殊的约定。使用也像普通的函数调用一样,Hook 里面其它的 Hook(如useEffect)会自动在合适的时候调用:

function FriendStatus(props) {
  const isOnline = useFriendStatus(props.friend.id);

  if (isOnline === null) {
    return 'Loading...';
  }
  return isOnline ? 'Online' : 'Offline';
}

function FriendListItem(props) {
  const isOnline = useFriendStatus(props.friend.id);

  return (
    <li style={{ color: isOnline ? 'green' : 'black' }}>
      {props.friend.name}
    </li>
  );
}

自定义 Hook 其实就是一个普通的函数定义,以use开头来命名也只是为了方便静态代码检测,不以它开头也完全不影响使用。在此不得不佩服 React 团队的巧妙设计。

八、Hooks 使用规则

使用 Hooks 的时候必须遵守 2 条规则:

  • 只能在代码的第一层调用 Hooks,不能在循环、条件分支或者嵌套函数中调用 Hooks。
  • 只能在Function Component或者自定义 Hook 中调用 Hooks,不能在普通的 JS 函数中调用。

Hooks 的设计极度依赖其定义时候的顺序,如果在后序的 render 中 Hooks 的调用顺序发生变化,就会出现不可预知的问题。上面 2 条规则都是为了保证 Hooks 调用顺序的稳定性。为了贯彻这 2 条规则,React 提供一个 ESLint plugin 来做静态代码检测:eslint-plugin-react-hooks

九、总结

本文深入介绍了 6 个 React 预定义 Hook 的使用方法和注意事项,并讲解了如何自定义 Hook,以及使用 Hooks 要遵循的一些约定。到此为止,Hooks 相关的内容已经介绍完了,内容比我刚开始计划的要多不少,想要彻底理解 Hooks 的设计是需要投入相当精力的,希望本文可以为你学习这一新特性提供一些帮助。

查看原文

赞 28 收藏 23 评论 2

JerryC 赞了文章 · 2019-07-10

React Fiber 原理介绍

欢迎关注我的公众号睿Talk,获取我最新的文章:
clipboard.png

一、前言

在 React Fiber 架构面世一年多后,最近 React 又发布了最新版 16.8.0,又一激动人心的特性:React Hooks 正式上线,让我升级 React 的意愿越来越强烈了。在升级之前,不妨回到原点,了解下人才济济的 React 团队为什么要大费周章,重写 React 架构,而 Fiber 又是个什么概念。

二、React 15 的问题

在页面元素很多,且需要频繁刷新的场景下,React 15 会出现掉帧的现象。请看以下例子:
https://claudiopro.github.io/...

clipboard.png

其根本原因,是大量的同步计算任务阻塞了浏览器的 UI 渲染。默认情况下,JS 运算、页面布局和页面绘制都是运行在浏览器的主线程当中,他们之间是互斥的关系。如果 JS 运算持续占用主线程,页面就没法得到及时的更新。当我们调用setState更新页面的时候,React 会遍历应用的所有节点,计算出差异,然后再更新 UI。整个过程是一气呵成,不能被打断的。如果页面元素很多,整个过程占用的时机就可能超过 16 毫秒,就容易出现掉帧的现象。

针对这一问题,React 团队从框架层面对 web 页面的运行机制做了优化,得到很好的效果。

clipboard.png

三、解题思路

解决主线程长时间被 JS 运算占用这一问题的基本思路,是将运算切割为多个步骤,分批完成。也就是说在完成一部分任务之后,将控制权交回给浏览器,让浏览器有时间进行页面的渲染。等浏览器忙完之后,再继续之前未完成的任务。

旧版 React 通过递归的方式进行渲染,使用的是 JS 引擎自身的函数调用栈,它会一直执行到栈空为止。而Fiber实现了自己的组件调用栈,它以链表的形式遍历组件树,可以灵活的暂停、继续和丢弃执行的任务。实现方式是使用了浏览器的requestIdleCallback这一 API。官方的解释是这样的:

window.requestIdleCallback()会在浏览器空闲时期依次调用函数,这就可以让开发者在主事件循环中执行后台或低优先级的任务,而且不会对像动画和用户交互这些延迟触发但关键的事件产生影响。函数一般会按先进先调用的顺序执行,除非函数在浏览器调用它之前就到了它的超时时间。

有了解题思路后,我们再来看看 React 具体是怎么做的。

四、React 的答卷

React 框架内部的运作可以分为 3 层:

  • Virtual DOM 层,描述页面长什么样。
  • Reconciler 层,负责调用组件生命周期方法,进行 Diff 运算等。
  • Renderer 层,根据不同的平台,渲染出相应的页面,比较常见的是 ReactDOM 和 ReactNative。

这次改动最大的当属 Reconciler 层了,React 团队也给它起了个新的名字,叫Fiber Reconciler。这就引入另一个关键词:Fiber。

Fiber 其实指的是一种数据结构,它可以用一个纯 JS 对象来表示:

const fiber = {
    stateNode,    // 节点实例
    child,        // 子节点
    sibling,      // 兄弟节点
    return,       // 父节点
}

为了加以区分,以前的 Reconciler 被命名为Stack Reconciler。Stack Reconciler 运作的过程是不能被打断的,必须一条道走到黑:

clipboard.png

而 Fiber Reconciler 每执行一段时间,都会将控制权交回给浏览器,可以分段执行:

clipboard.png

为了达到这种效果,就需要有一个调度器 (Scheduler) 来进行任务分配。任务的优先级有六种:

  • synchronous,与之前的Stack Reconciler操作一样,同步执行
  • task,在next tick之前执行
  • animation,下一帧之前执行
  • high,在不久的将来立即执行
  • low,稍微延迟执行也没关系
  • offscreen,下一次render时或scroll时才执行

优先级高的任务(如键盘输入)可以打断优先级低的任务(如Diff)的执行,从而更快的生效。

Fiber Reconciler 在执行过程中,会分为 2 个阶段。

clipboard.png

  • 阶段一,生成 Fiber 树,得出需要更新的节点信息。这一步是一个渐进的过程,可以被打断。
  • 阶段二,将需要更新的节点一次过批量更新,这个过程不能被打断。

阶段一可被打断的特性,让优先级更高的任务先执行,从框架层面大大降低了页面掉帧的概率。

五、Fiber 树

Fiber Reconciler 在阶段一进行 Diff 计算的时候,会生成一棵 Fiber 树。这棵树是在 Virtual DOM 树的基础上增加额外的信息来生成的,它本质来说是一个链表。

clipboard.png

Fiber 树在首次渲染的时候会一次过生成。在后续需要 Diff 的时候,会根据已有树和最新 Virtual DOM 的信息,生成一棵新的树。这颗新树每生成一个新的节点,都会将控制权交回给主线程,去检查有没有优先级更高的任务需要执行。如果没有,则继续构建树的过程:

clipboard.png

如果过程中有优先级更高的任务需要进行,则 Fiber Reconciler 会丢弃正在生成的树,在空闲的时候再重新执行一遍。

在构造 Fiber 树的过程中,Fiber Reconciler 会将需要更新的节点信息保存在Effect List当中,在阶段二执行的时候,会批量更新相应的节点。

六、总结

本文从 React 15 存在的问题出发,介绍 React Fiber 解决问题的思路,并介绍了 Fiber Reconciler 的工作流程。从Stack ReconcilerFiber Reconciler,源码层面其实就是干了一件递归改循环的事情,日后有机会的话,我再结合源码作进一步的介绍。

查看原文

赞 120 收藏 55 评论 10

JerryC 发布了文章 · 2019-07-08

关于 OKR 的一些方法论

欢迎来我的博客阅读:关于 OKR 的一些方法论

前言

OKR 是由前 Intel CEO,安迪·葛洛夫 构建的基本框架。

全称是:「Objective - Key Result」,既强调「目标」与衡量目标的「关键结果」

它是一套管理目标,让目标能落地的工具。
它在硅谷科技公司中广为人知,并被世界各地的许多组织采用。
它可以应用在组织中,也可以应用在个人的生活中,就像一种思考的模式。

过去两年多的 OKR 实践,有一些体会。
作为一个程序员,会自然的去寻找一个工具的最佳实践。

于是,有了这篇文章。

基本原理

OKR 原理很简单。

要用好 OKR,我的理解,需要把握三个核心:

  • 目标
  • 关键结果
  • 过程管理

它们分别回答了三个问题:

  • 应该做什么?
  • 如何衡量做到了?
  • 怎么落地?

然后,思考 OKR,我认为还需要 cover 到两点:

  • 看得到的结果
  • 说得出的价值

先抛一个不好的例子

来自于我曾经定过的一个 OKR:

O: 持续学习,提高自身战斗力

  • KR1: CSS3 学习,阅读《CSS揭秘》产出阅读笔记。
  • KR2: 提高英文阅读能力,阅读《Security Your NodeJS Application》,产出一篇译文。
  • KR3: 对 Eggjs 或 Vue2 框架的源码进行解读,产出一篇源码解析。

我想先按顺序来讲讲「目标」、「关键结果」、「过程管理」。
然后,再回过头来,看看这个例子为啥糟糕,可以怎样修改。

目标 Objective

欲望让我们起航,但只有专注、规划和学习才能到达成功的彼岸
组织的诞生

回到最初的时候,一个组织的诞生,绝大多数情况是由于一两个人的想法,然后以此为中心,开始聚拢更多有共同目标的人加入进来。

1976年,乔布斯成功说服沃茲尼克组装机器之后再拿去推销,他们的另一位朋友韦恩随后加入,三人在1976年4月1日成立苹果电脑公司。最初,Apple 仅仅是在卖组装电脑。

1996年,佩奇和布林在学校开始一项关于搜索的研究项目,开发出了搜索引擎 PageRank,后续改名 Google。最初,Google 仅仅是一个搜索引擎。

组织的使命

随着组织发展,人员壮大,这个能聚拢人的目标,必须要看得远。然后这个目标提升到用另一个词来形容 —「使命」。

Apple 的使命:「藉推广公平的资料使用惯例,建立用户对互联网之信任和信心」
Google 的使命:「整合全球信息,使人人皆可访问和收益」
阿里巴巴的使命:「让天下没有难做的生意」
有赞的使命:「帮助每一位重视产品和服务的商家成功」
以及最近我们团队的前端技术委员的使命:「以极致的技术高效支撑业务」

使命描述一般都很简洁,并且容易记忆,像一句广告词,能深深的刻在脑海里。
在工作中遇到问题的时候,这个使命就会一下子从脑海里蹦出来指引你找到答案。

其实在某个市场闲逛都有可能让你意识到这个市场有某个问题需要解决,而帮市场解决这个问题,就是一个使命。

阶段性的目标

为了一步步的达成「使命」,我们需要有目标。相对于使命,它粒度更小,且有时间限制。

所以,目标(Objective)应该:

  • 是阶段性的
  • 是有优先级的
  • 它需要能明确方向且鼓舞人心

目标,是 OKR 中最重要,最需要想清楚,最首要确定的。
在这里,需要回答:你有什么?你要什么?你能放弃什么?

重要与紧急

「鱼与熊掌不可得兼」,所以我们要有所取舍,事情排个优先级。
「重要-紧急象限」是一个不错的指导工具,第一次看到它是在柯维《高效能人士的7个习惯》中的第三个习惯「要事第一」。

重要-紧急

但在实施的过程中很有可能会遇到这样一个问题,紧急不重要的事情很紧急,总需要花时间和精力去处理它。然后重要不紧急的事情,会常常分配不到时间和精力。

那么就让重要不紧急的事情也变得紧急起来。

目标需要自上而下的关联

如果基础的商业问题没有解决,不论实现多少产品功能,团队整体的绩效一定会大打折扣。

在一个组织中,如果没有充分的理解上一层的目标,就很容易跑偏,没有真正在刀刃上使力,造成效率上的浪费。

达到充分的理解目标,是有难度的,对人的眼界、目标理解能力有很高的要求。这不仅仅是执行者责任,更是管理者的责任。

关键结果 Key Result

衡量目标是否达成

目标定下来了,如果不去执行和落地,那么它永远就只是一个目标。如何去衡量目标是否达到了,就是「关键结果」的任务。

在互联网产品中,通常可以量化的条件有:用户增长、用户激活、收入增长、产品性能、产品质量。

作为技术团队,会更加集中注意力在产品性能和产品质量上面,那么如何去找到这些方向的衡量指标,就要从实际出发了。

比如我们团队会用「质量系数 = BUG数/估时」,来感受一个项目的质量情况。虽然它会有些漏洞,但如果建立在互相信任的基础上,可以提供一定的参考价值。

有些挑战性
当达到成结果的时候,我们应该是欢呼雀跃般的兴奋,而不是理所应当的淡定。

定下一个关键结果之后,问一下自己,有多少信心可以完成。如果信心爆棚,就把目标定高些。如果信心不足,就把目标调低些。因为 OKR 的意义不在于完成目标,更重要的是它能挖掘团队以及个人的潜力。

如果觉得有必要的话,我们可以建立一个「信心指数」,用来帮助确定结果有足够的挑战性而不会让人失去信心。这个指数的开始值最好是 50%,然后通过过程管理来动态变更和追踪。

比如去年我负责的一个「优化微信小程序加载性能」项目中的关键结果:

  • 首屏加载时间 3s 内

未优化的加载时间是 6s+,回顾当时对目标的信心指数的话,大概是 20%。虽然最后因为部分不可控因素没有达到这个目标,只能维持在 3s-4s 之间。但是这个过程中能让人费尽脑汁的找到各种方法,大幅的提升了除首屏加载以外其他方面的加载体验,这也是额外的收获。

作为管理者,你要清楚的知道哪些人推一推会有更高的产出,哪些人实际执行情况会出现问题,要能看得到看得懂目前组织的目标和进度,并与成员进行同步。

过程管理

OKR 定下来了,在期限内,就要奔着目标努力奋进。尽管中途发现问题,也尽量不要在中途更改 OKR,让我们尽力跑完计划的阶段再回来总结。我们也可以把时间维度切小,比如把年度切分为半年度,把半年度切分为季度。

并且,目标定下来之后,要经常定期共同回顾,共同看见。而不是定下来了,就放在那里,否则过程中团队发生了问题,成员遇到了困难,很大可能会不被看到。

比较好的形式是每周都一起坐下来看看,每个人分享一下成果,或者说说遇到的困难,看能不能得到其他人的帮助。这个过程,能及时的看到问题,也能让成员对目标有更强的参与感。

那么,OKR应该以什么方式来呈现?《OKR工作法》一书中提供了一种参考:「四象限呈现形式」

四象限呈现

  • 第一象限:本周3-4件最重要的事情,并且进行优先级的排序
  • 第二象限:把OKR内容罗列出来,关注和更新每一项KR的信心指数
  • 第三象限:未来中长段时间中的计划,能让我们稍微看远一些。
  • 第四象限:关注那些影响目标的关键因素会不会掉链子,例如团队状态,系统状态等。也可以用红蓝黄颜色表示出来。

回过头看看那个糟糕的例子

糟糕的例子:

O: 持续学习,提高自身战斗力

  • KR1:CSS3 学习,阅读《CSS揭秘》 产出阅读笔记。
  • KR2:提高英文阅读能力,阅读《Security Your NodeJS Application》,产出一篇译文。
  • KR3: Vue2 框架的源码进行解读,产出一篇源码解析。

这个例子的背景是我 2017 年 4 月份加入到有赞,当时定的试用期内的其中一个目标。那时是我第一次认识和使用 OKR,只是单纯的把自身的技能提升计划给罗列了出来,看起来更像是一个 Todo List

现在回过头来看这一份 OKR,有不少问题:

  1. 目标没有描述出来价值,提升了自身战斗力,然后呢?并没有自上而下的关联团队和组织的目标。所以从目标上,就已经走偏了。
  2. 假设目标正确,KR 也没有起到能衡量目标是否达成的作用。例如 KR1 完成了,对目标的推进,并没有说服力。
  3. 最后把 OKR 用成了 Todo List。

那么我们从目标开始分析,当时作为一个新人加入到一个新的团队,对团队的技术栈和项目都很陌生,需要填补部分空白,快速上手。所以提升自身实力的底层诉求是:快速上手,胜任开发工作。

然后怎么衡量目的达到了呢?我们可以通过项目质量直接衡量,通过项目的熟悉程度来间接衡量。

修正后:

O: 快速上手,以专业的姿态胜任开发工作。

  • KR1: 质量系数平均在 0.3 以内。(质量系数 = BUG数/估时)
  • KR2: 代码评审评分平均 3.5 以上。(我们有 Code Review 机制,并且有评分环节)
  • KR3: 所参与项目评分在 4 以上。(项目也有评分环节)
  • KR4: 进行两次的项目分享。

那么如果达到这些关键结果,要通过学习框架,还是研究项目,还是熟悉业务,那就是根据实际迎刃而解的事情了。

最后

凡事预则立,不预则废 ——《礼记·中庸》

最后要注意的是,OKR 只是一个工具,当你有一个目标,它会给你一种落实目标的方法论。而如果一开始目标没有想清楚,想明白,那就很容易在错的路上越走越远。

每个团队都会有不同的风格,和不同的实际情况。理解方法和工具的原理,明白这么做是为了解决什么问题,然后再调整定制真正适合此时此刻的团队,才是最好的方法。

查看原文

赞 11 收藏 5 评论 1

JerryC 赞了文章 · 2019-06-18

你不知道的Virtual DOM(二):Virtual Dom的更新

欢迎关注我的公众号睿Talk,获取我最新的文章:
clipboard.png

一、前言

目前最流行的两大前端框架,React 和 Vue,都不约而同的借助 Virtual DOM 技术提高页面的渲染效率。那么,什么是 Virtual DOM ?它是通过什么方式去提升页面渲染效率的呢?本系列文章会详细讲解 Virtual DOM 的创建过程,并实现一个简单的 Diff 算法来更新页面。本文的内容脱离于任何的前端框架,只讲最纯粹的 Virtual DOM 。敲单词太累了,下文 Virtual DOM 一律用 VD 表示。

这是 VD 系列文章的第二篇,以下是本系列其它文章的传送门:
你不知道的 Virtual DOM(一):Virtual Dom 介绍
你不知道的 Virtual DOM(二):Virtual Dom 的更新
你不知道的 Virtual DOM(三):Virtual Dom 更新优化
你不知道的 Virtual DOM(四):key 的作用
你不知道的 Virtual DOM(五):自定义组件
你不知道的 Virtual DOM(六):事件处理 & 异步更新

本文将会实现一个简单的 VD Diff 算法,计算出差异并反映到真实的 DOM 上去。

二、思路

使用 VD 的框架,一般的设计思路都是页面等于页面状态的映射,即UI = render(state)。当需要更新页面的时候,无需关心 DOM 具体的变换方式,只需要改变state即可,剩下的事情(render)将由框架代劳。我们考虑最简单的情况,当 state 发生变化时,我们重新生成整个 VD ,触发比较的操作。上述过程分为以下四步:

  • state 变化,生成新的 VD
  • 比较 VD 与之前 VD 的异同
  • 生成差异对象(patch
  • 遍历差异对象并更新 DOM

差异对象的数据结构是下面这个样子,与每一个 VDOM 元素一一对应:

{
    type,
    vdom,
    props: [{
               type,
               key,
               value 
            }]
    children
}

最外层的 type 对应的是 DOM 元素的变化类型,有 4 种:新建、删除、替换和更新。props 变化的 type 只有2种:更新和删除。枚举值如下:

const nodePatchTypes = {
    CREATE: 'create node',
    REMOVE: 'remove node',
    REPLACE: 'replace node',
    UPDATE: 'update node'
}

const propPatchTypes = {
    REMOVE: 'remove prop',
    UPDATE: 'update prop'
}

三、代码实现

我们做一个定时器,500 毫秒运行一次,每次对 state 加 1。页面的li元素的数量随着 state 而变。

let state = { num: 5 };
let timer;
let preVDom;

function render(element) {
    // 初始化的VD
    const vdom = view();
    preVDom = vdom;

    const dom = createElement(vdom);
    element.appendChild(dom);

    
    timer = setInterval(() => {
        state.num += 1;
        tick(element);
    }, 500);
}

function tick(element) {
    if (state.num > 20) {
        clearTimeout(timer);
        return;
    }

    const newVDom = view();
}

function view() {
    return (
        <div>
            Hello World
            <ul>
                {
                    // 生成元素为0到n-1的数组
                    [...Array(state.num).keys()]
                        .map( i => (
                            <li id={i} class={`li-${i}`}>
                                第{i * state.num}
                            </li>
                        ))
                }
            </ul>
        </div>
    );
}

接下来,通过对比 2 个 VD,生成差异对象。

function tick(element) {
    if (state.num > 20) {
        clearTimeout(timer);
        return;
    }

    const newVDom = view();

    // 生成差异对象
    const patchObj = diff(preVDom, newVDom);
}

function diff(oldVDom, newVDom) {
    // 新建 node
    if (oldVDom == undefined) {
        return {
            type: nodePatchTypes.CREATE,
            vdom: newVDom
        }
    }

    // 删除 node
    if (newVDom == undefined) {
        return {
            type: nodePatchTypes.REMOVE
        }
    }

    // 替换 node
    if (
        typeof oldVDom !== typeof newVDom ||
        ((typeof oldVDom === 'string' || typeof oldVDom === 'number') && oldVDom !== newVDom) ||
        oldVDom.tag !== newVDom.tag
    ) {
       return {
           type: nodePatchTypes.REPLACE,
           vdom: newVDom
       } 
    }

    // 更新 node
    if (oldVDom.tag) {
        // 比较 props 的变化
        const propsDiff = diffProps(oldVDom, newVDom);

        // 比较 children 的变化
        const childrenDiff = diffChildren(oldVDom, newVDom);
        
        // 如果 props 或者 children 有变化,才需要更新
        if (propsDiff.length > 0 || childrenDiff.some( patchObj => (patchObj !== undefined) )) {
            return {
                type: nodePatchTypes.UPDATE,
                props: propsDiff,
                children: childrenDiff
            }   
        }
        
    }
}

// 比较 props 的变化
function diffProps(oldVDom, newVDom) {
    const patches = [];

    const allProps = {...oldVDom.props, ...newVDom.props};

    // 获取新旧所有属性名后,再逐一判断新旧属性值
    Object.keys(allProps).forEach((key) => {
            const oldValue = oldVDom.props[key];
            const newValue = newVDom.props[key];

            // 删除属性
            if (newValue == undefined) {
                patches.push({
                    type: propPatchTypes.REMOVE,
                    key
                });
            } 
            // 更新属性
            else if (oldValue == undefined || oldValue !== newValue) {
                patches.push({
                    type: propPatchTypes.UPDATE,
                    key,
                    value: newValue
                });
            }
        }
    )

    return patches;
}

// 比较 children 的变化
function diffChildren(oldVDom, newVDom) {
    const patches = [];
    
    // 获取子元素最大长度
    const childLength = Math.max(oldVDom.children.length, newVDom.children.length);

    // 遍历并diff子元素
    for (let i = 0; i < childLength; i++) {
        patches.push(diff(oldVDom.children[i], newVDom.children[i]));
    }

    return patches;
}

计算得出的差异对象是这个样子的:

{
    type: "update node",
    props: [],
    children: [
        null, 
        {
            type: "update node",
            props: [],
            children: [
                null, 
                {
                    type: "update node",
                    props: [],
                    children: [
                        null, 
                        {
                            type: "replace node",
                            vdom: 6
                        }
                    ]
                }
            ]
        },
        {
            type: "create node",
            vdom: {
                tag: "li",
                props: {
                    id: 5,
                    class: "li-5"
                },
                children: ["第", 30]
            }
        }
    ]
}

下一步就是遍历差异对象并更新 DOM 了:

function tick(element) {
    if (state.num > 20) {
        clearTimeout(timer);
        return;
    }

    const newVDom = view();

    // 生成差异对象
    const patchObj = diff(preVDom, newVDom);

    preVDom = newVDom;

    // 给 DOM 打个补丁
    patch(element, patchObj);
}

// 给 DOM 打个补丁
function patch(parent, patchObj, index=0) {
    if (!patchObj) {
        return;
    }

    // 新建元素
    if (patchObj.type === nodePatchTypes.CREATE) {
        return parent.appendChild(createElement(patchObj.vdom));
    }

    const element = parent.childNodes[index];

    // 删除元素
    if (patchObj.type === nodePatchTypes.REMOVE) {
        return parent.removeChild(element);
    }

    // 替换元素
    if (patchObj.type === nodePatchTypes.REPLACE) {
        return parent.replaceChild(createElement(patchObj.vdom), element);
    }

    // 更新元素
    if (patchObj.type === nodePatchTypes.UPDATE) {
        const {props, children} = patchObj;

        // 更新属性
        patchProps(element, props);

        // 更新子元素
        children.forEach( (patchObj, i) => {
            // 更新子元素时,需要将子元素的序号传入
            patch(element, patchObj, i)
        });
    }
}

// 更新属性
function patchProps(element, props) {
    if (!props) {
        return;
    }

    props.forEach( patchObj => {
        // 删除属性
        if (patchObj.type === propPatchTypes.REMOVE) {
            element.removeAttribute(patchObj.key);
        } 
        // 更新或新建属性
        else if (patchObj.type === propPatchTypes.UPDATE) {
            element.setAttribute(patchObj.key, patchObj.value);
        }
    })
}

到此为止,整个更新的流程就执行完了。可以看到页面跟我们预期的一样,每 500 毫秒刷新一次,构造渲染树和绘制页面花的时间也非常少。
clipboard.png

作为对比,如果我们在生成新的 VD 后,不经过比较,而是直接重新渲染整个 DOM 的时候,会怎样呢?我们修改一下代码:

function tick(element) {
    if (state.num > 20) {
        clearTimeout(timer);
        return;
    }

    const newVDom = view();
    newDom = createElement(newVDom);

    element.replaceChild(newDom, dom);

    dom = newDom;

    /*
    // 生成差异对象
    const patchObj = diff(preVDom, newVDom);

    preVDom = newVDom;

    // 给 DOM 打个补丁
    patch(element, patchObj);
    */
}

效果如下:
clipboard.png

可以看到,构造渲染树(Rendering)和绘制页面(Painting)的时间要多一些。但另一方面花在 JS 计算(Scripting)的时间要少一些,因为不需要比较节点的变化。如果算总时间的话,重新渲染整个 DOM 花费的时间反而更少,这是为什么呢?

其实原因很简单,因为我们的 DOM 树太简单了!节点很少,使用到的 css 也很少,所以构造渲染树和绘制页面就花不了多少时间。VD 真正的效果还是要在真实的项目中才体现得出来。

四、总结

本文详细介绍如何实现一个简单的 VD Diff 算法,再根据计算出的差异去更新真实的 DOM 。然后对性能做了一个简单的分析,得出使用 VD 在减少渲染时间的同时增加了 JS 计算时间的结论。基于当前这个版本的代码还能做怎样的优化呢,请看下一篇的内容:你不知道的Virtual DOM(三):Virtual Dom更新优化

P.S.: 想看完整代码见这里,如果有必要建一个仓库的话请留言给我:代码

查看原文

赞 65 收藏 38 评论 9

JerryC 赞了文章 · 2019-06-18

你不知道的Virtual DOM(一):Virtual Dom介绍

欢迎关注我的公众号睿Talk,获取我最新的文章:
clipboard.png

一、前言

目前最流行的两大前端框架,React和Vue,都不约而同的借助Virtual DOM技术提高页面的渲染效率。那么,什么是Virtual DOM?它是通过什么方式去提升页面渲染效率的呢?本系列文章会详细讲解Virtual DOM的创建过程,并实现一个简单的Diff算法来更新页面。本文的内容脱离于任何的前端框架,只讲最纯粹的Virtual DOM。敲单词太累了,下文Virtual DOM一律用VD表示。

这是VD系列文章的开篇,以下是本系列其它文章的传送门:
你不知道的Virtual DOM(一):Virtual Dom介绍
你不知道的Virtual DOM(二):Virtual Dom的更新
你不知道的Virtual DOM(三):Virtual Dom更新优化
你不知道的Virtual DOM(四):key的作用
你不知道的Virtual DOM(五):自定义组件
你不知道的Virtual DOM(六):事件处理&异步更新

二、VD是什么

本质上来说,VD只是一个简单的JS对象,并且最少包含tag、props和children三个属性。不同的框架对这三个属性的命名会有点差别,但表达的意思是一致的。它们分别是标签名(tag)、属性(props)和子元素对象(children)。下面是一个典型的VD对象例子:

{
    tag: "div",
    props: {},
    children: [
        "Hello World", 
        {
            tag: "ul",
            props: {},
            children: [{
                tag: "li",
                props: {
                    id: 1,
                    class: "li-1"
                },
                children: ["第", 1]
            }]
        }
    ]
}

VD跟dom对象有一一对应的关系,上面的VD是由以下的HTML生成的

<div>
    Hello World
    <ul>
        <li id="1" class="li-1">
            第1
        </li>
    </ul>
</div>

一个dom对象,比如li,由tag(li), props({id: 1, class: "li-1"})children(["第", 1])三个属性来描述。

三、为什么需要VD

VD 最大的特点是将页面的状态抽象为 JS 对象的形式,配合不同的渲染工具,使跨平台渲染成为可能。如 React 就借助 VD 实现了服务端渲染、浏览器渲染和移动端渲染等功能。

此外,在进行页面更新的时候,借助VD,DOM 元素的改变可以在内存中进行比较,再结合框架的事务机制将多次比较的结果合并后一次性更新到页面,从而有效地减少页面渲染的次数,提高渲染效率。我们先来看下页面的更新一般会经过几个阶段。
clipboard.png

从上面的例子中,可以看出页面的呈现会分以下3个阶段:

  • JS计算
  • 生成渲染树
  • 绘制页面

这个例子里面,JS计算用了691毫秒,生成渲染树578毫秒,绘制73毫秒。如果能有效的减少生成渲染树和绘制所花的时间,更新页面的效率也会随之提高。
通过VD的比较,我们可以将多个操作合并成一个批量的操作,从而减少dom重排的次数,进而缩短了生成渲染树和绘制所花的时间。至于如何基于VD更有效率的更新dom,是一个很有趣的话题,日后有机会将另写一篇文章介绍。

四、如何实现VD与真实DOM的映射

我们先从如何生成VD说起。借助JSX编译器,可以将文件中的HTML转化成函数的形式,然后再利用这个函数生成VD。看下面这个例子:

function render() {
    return (
        <div>
            Hello World
            <ul>
                <li id="1" class="li-1">
                    第1
                </li>
            </ul>
        </div>
    );
}

这个函数经过JSX编译后,会输出下面的内容:

function render() {
    return h(
        'div',
        null,
        'Hello World',
        h(
            'ul',
            null,
            h(
                'li',
                { id: '1', 'class': 'li-1' },
                '\u7B2C1'
            )
        )
    );
}

这里的h是一个函数,可以起任意的名字。这个名字通过babel进行配置:

// .babelrc文件
{
  "plugins": [
    ["transform-react-jsx", {
      "pragma": "h"    // 这里可配置任意的名称
    }]
  ]
}

接下来,我们只需要定义h函数,就能构造出VD

function flatten(arr) {
    return [].concat.apply([], arr);
}

function h(tag, props, ...children) {
    return {
        tag, 
        props: props || {}, 
        children: flatten(children) || []
    };
}

h函数会传入三个或以上的参数,前两个参数一个是标签名,一个是属性对象,从第三个参数开始的其它参数都是children。children元素有可能是数组的形式,需要将数组解构一层。比如:

function render() {
    return (
        <ul>
            <li>0</li>
            {
                [1, 2, 3].map( i => (
                    <li>{i}</li>
                ))
            }
        </ul>
    );
}

// JSX编译后
function render() {
    return h(
        'ul',
        null,
        h(
            'li',
            null,
            '0'
        ),
        /*
         * 需要将下面这个数组解构出来再放到children数组中
         */
        [1, 2, 3].map(i => h(
            'li',
            null,
            i
        ))
    );
}

继续之前的例子。执行h函数后,最终会得到如下的VD对象:

{
    tag: "div",
    props: {},
    children: [
        "Hello World", 
        {
            tag: "ul",
            props: {},
            children: [{
                tag: "li",
                props: {
                    id: 1,
                    class: "li-1"
                },
                children: ["第", 1]
            }]
        }
    ]
}

下一步,通过遍历VD对象,生成真实的dom

// 创建dom元素
function createElement(vdom) {
    // 如果vdom是字符串或者数字类型,则创建文本节点,比如“Hello World”
    if (typeof vdom === 'string' || typeof vdom === 'number') {
        return doc.createTextNode(vdom);
    }

    const {tag, props, children} = vdom;

    // 1. 创建元素
    const element = doc.createElement(tag);

    // 2. 属性赋值
    setProps(element, props);

    // 3. 创建子元素
    // appendChild在执行的时候,会检查当前的this是不是dom对象,因此要bind一下
    children.map(createElement)
            .forEach(element.appendChild.bind(element));

    return element;
}

// 属性赋值
function setProps(element, props) {
    for (let key in props) {
        element.setAttribute(key, props[key]);
    }
}

createElement函数执行完后,dom元素就创建完并展示到页面上了(页面比较丑,不要介意...)。

clipboard.png

五、总结

本文介绍了VD的基本概念,并讲解了如何利用JSX编译HTML标签,然后生成VD,进而创建真实dom的过程。下一篇文章将会实现一个简单的VD Diff算法,找出2个VD的差异并将更新的元素映射到dom中去:你不知道的Virtual DOM(二):Virtual Dom的更新

P.S.: 想看完整代码见这里,如果有必要建一个仓库的话请留言给我:代码

查看原文

赞 398 收藏 304 评论 20

JerryC 发布了文章 · 2019-06-10

BeautyWe.js 一套专注于微信小程序的开发范式

欢迎来我的博客阅读:BeautyWe.js 一套专注于微信小程序的开发范式

官网:beautywejs.com
Repo: beautywe

一个简单的介绍

BeautyWe.js 是什么?

它是一套专注于微信小程序的企业级开发范式,它的愿景是:

让企业级的微信小程序项目中的代码,更加简单、漂亮。

为什么要这样命名呢?

Write beautiful code for wechat mini program by the beautifulwe!

「We」 既是我们的 We,也是微信的 We,Both beautiful!

那么它有什么卖点呢?

  1. 专注于微信小程序环境,写原汁原味的微信小程序代码。
  2. 由于只专注于微信小程序,它的源码也很简单。
  3. 插件化的编程方式,让复杂逻辑更容易封装。
  4. 再加上一些配套设施:

    1. 一些官方插件。
    2. 一套开箱即用,包含了工程化、项目规范以及微信小程序环境独特问题解决方案的框架。
    3. 一个CLI工具,帮你快速创建应用,页面,组件等。

它由以下几部分组成:

  • 一个插件化的核心 - BeautyWe Core
    对 App、Page 进行抽象和包装,保持传统微信小程序开发姿势,同时开放部分原生能力,让其具有「可插件化」的能力。
  • 一些官方插件BeautyWe Plugins
    得益于 Core 的「可插件化」特性,封装复杂逻辑,实现可插拔。官方对于常见的需求提供了一些插件:如增强存储、发布/订阅、状态机、Logger、缓存策略等。
  • 一套开箱即用的项目框架 - BeautyWe Framework
    描述了一种项目的组织形式,开箱即用,集成了 BeautyWe Core ,并且提供了如:全局窗口、开发规范、多环境开发、全局配置、NPM 等解决方案。
  • 一个CLI工具 - BeautyWe Cli
    提供快速创建应用、页面、插件,以及项目构建功能的命令行工具。并且还支持自定义的创建模板。

一个简单的例子

下载

用 BeautyWe 包装你的应用

之后,你就能使用 BeautyWe Plugin 提供的能力了。

开放原生App/Page,支持插件化

new BtApp({...}) 的执行结果是对原生的应用进行包装,其中包含了「插件化」的处理,然后返回一个新的实例,这个实例适配原生的 App() 方法。

下面来讲讲「插件化」到底做了什么事情。

首先,插件化开放了原生 App 的四种能力:

  1. Data 域
    把插件的 Data 域合并到原生 App 的 Data 域中,这一块很容易理解。
  2. 原生钩子函数
    使原生钩子函数(如 onShow, onLoad)可插件化。让原生App与多个插件可以同时监听同一个钩子函数。如何工作的,下面会细说。
  3. 事件钩子函数
    使事件钩子函数(与 view 层交互的钩子函数),尽管在实现上有一些差异,但是实现原理跟「原生钩子函数」一样的。
  4. 自定义方法
    让插件能够给使用者提供 API。为了保证插件提供的 API 足够的优雅,支持当调用插件 API 的时候(如 event 插件 this.event.on(...)),API 方法内部仍然能通过 this 获取到原生实例。

钩子函数的插件化

原生钩子函数,事件钩子函数我们统一称为「钩子函数」。

对于每一个钩子函数,内部是维护一个以 Series Promise 方式执行的执行队列。

onShow 为例,将会以这样的形式执行:

native.onShow → pluginA.onShow → pluginB.onShow → ...

下面深入一下插件化的原理

beautywe pluggable

工作原理是这样的:

  1. 经过 new BtApp(...) 包装,所有的钩子函数,都会有一个独立的执行队列,
  2. 首先会把原生的各个钩子函数 push 到对应的队列中。然后每 use 插件的时候,都会分解插件的钩子函数,往对应的队列 push
  3. Native App(原生)触发某个钩子的时候,BtApp 会以 Promise Series 的形式按循序执行对应队列里面的函数。
  4. 特殊的,onLaunchonLoad 的执行队列中,会在队列顶部插入一个初始化的任务(initialize),它会以同步的方式按循序执行 Initialize Queue 里面的函数。这正是插件生命周期函数中的 plugin.initialize

这种设计能提供以下功能:

  1. 可插件化。
    只需要往对应钩子函数的事件队列中插入任务。
  2. 支持异步。
    由于是以 Promise Series 方式运行的,其中一个任务返回一个 Promise,下一个任务会等待这个任务完成再开始。如果发生错误,会流转到原生的 onError() 中。
  3. 解决了微信小程序 app.jsgetApp() === undefinded 问题。
    造成这个问题,本质是因为 App() 的时候,原生实例未创建。但是由于 Promise 在 event loop 中是一个微任务,被注册在下一次循环。所以 Promise 执行的时候 App() 早已经完成了。

一些官方插件

BeautyWe 官方提供了一系列的插件:

  1. 增强存储: Storage
  2. 数据列表:List Page
  3. 缓存策略:Cache
  4. 日志:Logger
  5. 事件发布/订阅:Event
  6. 状态机:Status

它们的使用很简单,哪里需要插哪里。
由于篇幅的原因,下面挑几个比较有趣的来讲讲,更多的可以看看官方文档:BeautyWe

增强存储 Storage

该功能由 @beautywe/plugin-storage 提供。

由于微信小程序原生的数据存储生命周期跟小程序本身一致,即除用户主动删除或超过一定时间被自动清理,否则数据都一直可用。

所以该插件在 wx.getStorage/setStorage 的基础上,提供了两种扩展能力:

  1. 过期控制
  2. 版本隔离

一些简单的例子

安装

import { BtApp } from '@beautywe/core';
import storage from '@beautywe/plugin-storage';

const app = new BtApp();
app.use(storage());

过期控制

// 7天后过期
app.storage.set('name', 'jc', { expire: 7 });

版本隔离

app.use({ appVersion: '0.0.1' });
app.set('name', 'jc');

// 返回 jc
app.get('name');

// 当版本更新后
app.use({ appVersion: '0.0.2' });

// 返回 undefined;
app.get('name');

更多的查看 @beautywe/plugin-storage 官方文档

数据列表 List Page

对于十分常见的数据列表分页的业务场景,@beautywe/plugin-listpage 提供了一套打包方案:

  1. 满足常用「数据列表分页」的业务场景
  2. 支持分页
  3. 支持多个数据列表
  4. 自动捕捉下拉重载:onPullDownRefresh
  5. 自动捕捉上拉加载:onReachBottom
  6. 自带请求锁,防止帕金森氏手抖用户
  7. 简单优雅的 API

一个简单的例子:

import BeautyWe from '@beautywe/core';
import listpage from '@beautywe/plugin-listpage';

const page = new BeautyWe.BtPage();

// 使用 listpage 插件
page.use(listpage({
    lists: [{
        name: 'goods',  // 数据名
        pageSize: 20,   // 每页多少条数据,默认 10

        // 每一页的数据源,没次加载页面时,会调用函数,然后取返回的数据。
        fetchPageData({ pageNo, pageSize }) {
        
            // 获取数据
            return API.getGoodsList({ pageNo, pageSize })
            
                // 有时候,需要对服务器的数据进行处理,dataCooker 是你定义的函数。
                .then((rawData) => dataCooker(rawData));
        },
    }],
    enabledPullDownRefresh: true,    // 开启下拉重载, 默认 false
    enabledReachBottom: true,    // 开启上拉加载, 默认 false
}));

// goods 数据会被加载到,goods 为上面定义的 name
// this.data.listPage.goods = {
//     data: [...],     // 视图层,通过该字段来获取具体的数据
//     hasMore: true,   // 视图层,通过该字段来识别是否有下一页
//     currentPage: 1,  // 视图层,通过该字段来识别当前第几页
//     totalPage: undefined,
// }

只需要告诉 listpage 如何获取数据,它会自动处理「下拉重载」、「上拉翻页」的操作,然后把数据更新到 this.data.listPage.goods 下。

View 层只需要描述数据怎么展示:

<view class="good" wx:for="listPage.goods.data">
    ...
</view>
<view class="no-more" wx:if="listPage.goods.hasMore === false">
    没有更多了
</view>

listpage 还支持多数据列表等其他更多配置,详情看:@beautywe/plugin-listpage

缓存策略 Cache

@beautywe/plugin-cache 提供了一个微信小程序端缓存策略,其底层由 super-cache 提供支持。

特性

  1. 提供一套「服务端接口耗时慢,但加载性能要求高」场景的解决方案
  2. 满足最基本的缓存需求,读取(get)和保存(set)
  3. 支持针对缓存进行逻辑代理
  4. 灵活可配置的数据存储方式

How it work

一般的请求数据的形式是,页面加载的时候,从服务端获取数据,然后等待数据返回之后,进行页面渲染:

但这种模式,会受到服务端接口耗时,网络环境等因素影响到加载性能。

对于加载性能要求高的页面(如首页),一般的 Web 开发我们有很多解决方案(如服务端渲染,服务端缓存,SSR 等)。
但是也有一些环境不能使用这种技术(如微信小程序)。

Super Cache 提供了一个中间数据缓存的解决方案:

思路:

  1. 当你需要获取一个数据的时候,如果有缓存,先把旧的数据给你。
  2. 然后再从服务端获取新的数据,刷新缓存。
  3. 如果一开始没有缓存,则请求服务端数据,再把数据返回。
  4. 下一次请求缓存,从第一步开始。

这种解决方案,舍弃了一点数据的实时性(非第一次请求,只能获取上一次最新数据),大大提高了前端的加载性能。
适合的场景:

  1. 数据实时性要求不高。
  2. 服务端接口耗时长。

使用

import { BtApp } from '@beautywe/core';
import cache from '@beautywe/plugin-cache';

const app = new BtApp();
app.use(cache({
    adapters: [{
        key: 'name',
        data() {
            return API.fetch('xxx/name');
        }
    }]
}));

假设 API.fetch('xxx/name') 是请求服务器接口,返回数据:data_from_server

那么:

app.cache.get('name').then((value) => {
    // value: 'data_from_server'  
});

更多的配置,详情看:@beautywe/plugin-cache

日志 Logger

@beautywe/logger-plugin 提供的一个轻量的日志处理方案,它支持:

  1. 可控的 log level
  2. 自定义前缀
  3. 日志统一处理

使用

import { BtApp } from '@beautywe/core';
import logger from '@beautywe/plugin-logger';

const page = new BtApp();

page.use(logger({
    // options
}));

API

page.logger.info('this is info');
page.logger.warn('this is warn');
page.logger.error('this is error');
page.logger.debug('this is debug');

// 输出
// [info] this is info
// [warn] this is warn
// [error] this is error
// [debug] this is debug

Level control

可通过配置来控制哪些 level 该打印:

page.use(logger({
    level: 'warn',
}));

那么 warn 以上的 log (info, debug)就不会被打印,这种满足于开发和生成环境对 log 的不同需求。

level 等级如下:

Logger.LEVEL = {
    error: 1,
    warn: 2,
    info: 3,
    debug: 4,
};

更多的配置,详情看:@beautywe/plugin-logger

BeautyWe Framework

@beautywe/core@beautywe/plugin-... 给小程序提供了:

  1. 开放原生,支持插件化 —— by core
  2. 各种插件 —— by plugins

但是,还有很多的开发中实际还会遇到的痛点,是上面两个解决不到的。
如项目的组织、规范、工程化、配置、多环境等等

这些就是,「BeautyWe Framework」要解决的范畴。

它作为一套开箱即用的项目框架,提供了这些功能:

  • 集成 BeautyWe Core
  • NPM 支持
  • 全局窗口
  • 全局 Page,Component
  • 全局配置文件
  • 多环境开发
  • Example Pages
  • 正常项目需要的标配:ES2015+,sass,uglify,watch 等
  • 以及我们认为良好的项目规范(eslint,commit log,目录结构等)

也是由于篇幅原因,挑几个有趣的来讲讲,更多的可以看看官方文档:BeautyWe

快速创建

首先安装 @beautywe/cli

$ npm i @beautywe/cli -g

创建应用

$ beautywe new app

> appName: my-app
> version: 0.0.1
> appid: 123456
> 这样可以么:
> {
>    "appName": "my-app",
>    "version": "0.0.1",
>    "appid": "123456"
> }

回答几个问题之后,项目就生成了:

my-app
├── gulpfile.js
├── package.json
└── src
    ├── app.js
    ├── app.json
    ├── app.scss
    ├── assets
    ├── components
    ├── config
    ├── examples
    ├── libs
    ├── npm
    ├── pages
    └── project.config.json

创建页面、组件、插件

页面

  1. 主包页面:beautywe new page <path|name>
  2. 分包页面:beautywe new page --subpkg <subPackageName> <path|name>

组件

  1. beautywe new component <name>

插件

  1. beautywe new plugin <name>

自定义模板

./.templates 目录中,存放着快速创建命令的创建模板:

$ tree .templates

.templates
├── component
│   ├── index.js
│   ├── index.json
│   ├── index.scss
│   └── index.wxml
├── page
│   ├── index.js
│   ├── index.json
│   ├── index.scss
│   └── index.wxml
└── plugin
    └── index.js

可以修改里面的模板,来满足项目级别的自定义模板创建。

全局窗口

我们都知道微信小程序是「单窗口」的交互平台,一个页面对应一个窗口。
而在业务开发中,往往会有诸如这种述求:

  1. 自定义的 toast 样式
  2. 页面底部 copyright
  3. 全局的 loading 样式
  4. 全局的悬浮控件

......

稍微不优雅的实现可以是分别做成独立的组件,然后每一个页面都引入进来。
这种做法,我们会有很多的重复代码,并且每次新建页面,都要引入一遍,后期维护也会很繁琐。

而「全局窗口」的概念是:希望所有页面之上有一块地方,全局性的逻辑和交互,可以往里面搁。

global-view 组件

这是一个自定义组件,源码在 /src/components/global-view

每个页面的 wxml 只需要在顶层包一层:

<global-view id="global-view">
    ...
</global-view>

需要全局实现的交互、样式、组件,只需要维护这个组件就足够了。

全局配置文件

src/config/ 目录中,可以存放各种全局的配置文件,并且支持以 Node.js 的方式运行。(得益于 Node.js Power 特性)。

src/config/logger.js:

const env = process.env.RUN_ENV || 'dev';

const logger = Object.assign({
    prefix: 'BeautyWe',
    level: 'debug',
}, {
    // 开发环境的配置
    dev: {
        level: 'debug',
    },
    // 测试环境的配置
    test: {
        level: 'info',
    },
    // 线上环境的配置
    prod: {
        level: 'warn',
    },
}[env] || {});

module.exports.logger = logger;

然后我们可以这样读取到 config 内容:

import { logger } from '/config/index';

// logger.level 会根据环境不同而不同。

Beautywe Framework 默认会把 config 集成到 getApp() 的示例中:

getApp().config;

多环境开发

BeautyWe Framework 支持多环境开发,其中预设了三套策略:

  • dev
  • test
  • prod

我们可以通过命令来运行这三个构建策略:

beautywe run dev
beautywe run test
beautywe run prod

三套环境的差异

Beautywe Framework 源码默认在两方面使用了多环境:

  • 构建任务(gulpfile.js/env/...
  • 全局配置(src/config/...

构建任务的差异

构建任务说明devtestprod
clean清除dist文件
copy复制资源文件
scripts编译JS文件
sass编译scss文件
npm编译npm文件
nodejs-power编译Node.js文件
watch监听文件修改
scripts-min压缩JS文件
sass-min压缩scss文件
npm-min压缩npm文件
image-min压缩图片文件
clean-example清除示例页面

Node.js Power

Beautywe Framework 的代码有两种运行环境:

  1. Node.js 运行环境,如构建任务等。
  2. 微信小程序运行环境,如打包到 dist 文件夹的代码。

运行过程

Node.js Power 本质是一种静态编译的实现。
把某个文件在 Node.js 环境运行的结果,输出到微信小程序运行环境中,以此来满足特定的需求。

Node.js Power 会把项目中 src 目录下类似 xxx.nodepower.js 命名的文件,以 Node.js 来运行,
然后把运行的结果,以「字面量对象」的形式写到 dist 目录下对应的同名文件 xxx.nodepower.js 文件去。

src/config/index.nodepower.js 为例:

const fs = require('fs');
const path = require('path');

const files = fs.readdirSync(path.join(__dirname));

const result = {};

files
    .filter(name => name !== 'index.js')
    .forEach((name) => {
        Object.assign(result, require(path.join(__dirname, `./${name}`)));
    });

module.exports = result;

该文件,经过 Node.js Power 构建之后:

dist/config/index.nodepower.js:

module.exports = {
    "appInfo": {
        "version": "0.0.1",
        "env": "test",
        "appid": "wx85fc0d03fb0b224d",
        "name": "beautywe-framework-test-app"
    },
    "logger": {
        "prefix": "BeautyWe",
        "level": "info"
    }
};

这就满足了,随意往 src/config/ 目录中扩展配置文件,都能被自动打包。

Node.js Power 已经被集成到多环境开发的 dev, test, prod 中去。

当然,你可以手动运行这个构建任务:

$ gulp nodejs-power

NPM

BeautyWe Framework 实现支持 npm 的原理很简单,总结一句话:

使用 webpack 打包 src/npm/index.js ,以 commonjs 格式输出到 dist/npm/index.js

npm-works

这样做的好处:

  1. 实现简单。
  2. 让 npm 包能集中管理,每次引入依赖,都好好的想一下,避免泛滥(尤其在多人开发中)。
  3. 使用 ll dist/npm/index.js 命令能快速看到项目中的 npm 包使占了多少容量。

新增 npm 依赖

src/npm/index.js 文件中,进行 export:

export { default as beautywe } from '@beautywe/core';

然后在其他文件 import:

import { beautywe } from './npm/index';

更多

总的来说,BeautyWe 是一套微信小程序的开发范式。

coreplugins 扩展原生,提供复杂逻辑的封装和插拔式使用。

framework 则负责提供一整套针对于微信小程序的企业级项目解决方案,开箱即用。

其中还有更多的内容,欢迎浏览官网:beautywejs.com

查看原文

赞 14 收藏 9 评论 5

JerryC 关注了专栏 · 2019-03-19

有赞技术

有赞技术相关内容

关注 6665

JerryC 赞了文章 · 2018-11-13

nest后端开发实战(二)——分层

前言

分层是解决软件复杂度很好的方法,它能够降低耦合、增加复用。典型的java后端开发大多分为三层,几乎成了标准模式,但是node社区对于分层的讨论却很少。node后端是否需要分层?如何分层?本文将从个人的角度提供一些思路。

是否必要分层?如何分层?

个人的结论是:如果想做一个正儿八经的node后台应用,一定需要分层,java的三层架构,同样适用于node。结构如下:
clipboard.png

dao层

dao(data access object),数据访问对象,位于最下层,和数据库打交道。它的基本职责是封装数据的访问细节,为上层提供友好的数据存取接口。一般是各种数据库查询语句,缓存也可以在这层做。

无论是nest还是egg,官方demo里都没有明确提到dao层,直接在service层操作数据库了。这对于简单的业务逻辑没问题,如果业务逻辑变得复杂,service层的维护将会变得非常困难。业务一开始一般都很简单,它一定会向着复杂的方向演化,如果从长远考虑,一开始就应该保留dao层。

分享两点dao层的建议:

1、以实体为中心定义类型描述。
后端建模的一大产出是领域实体模型,后续的业务逻辑其实就是对实体模型的增删改查。利用ts对类型的丰富支持,可以先将实体模型的类型描述定义出来,这将极大的方便上层业务逻辑的实现。我一般会将实体相关的类型、常量等都定义到一个文件,命名为xxx.types.ts。定义到一个文件的好处是,编码规范好落实,书写和引用也非常方便,由于没有太多逻辑,即使文件稍微大一点,可读性也不会降低太多。

用po和dto来描述实体及其周边。po是持久化对象和数据库的表结构一一对应;dto数据传输对象则很灵活,可以在丰富的场景描述入参或返回值。下面是个user实体的例子:

// user.types.ts

/**
 * 用户持久化对象
 */
export interface UserPo {
    id: number;
    name: string; // 姓名
    gender: Gender; // 性别
    desc: string; // 介绍

}
/**
 * 新建用户传输对象
 */
export interface UserAddDto {
    name: string;
    gender?: Gender;
    desc?: string;
}
/**
 * 性别
 */
export enum Gender {
    Unknown,
    Male,
    Female,
}

虽然ts提供了强大的类型系统,如果不能总结出一套最佳实践出来,同样会越写越乱。全盘使用不是一个好的选择,因为这样会失去很多的灵活性。我们需要的是在某些必须的场景,坚持使用。

2、不推荐orm框架
orm的初心很好,它试图完全将对象和数据库映射自动化,让使用者不再关心数据库。过度的封装一定会带来另外一个问题——隐藏复杂度的上升。个人觉得,比起查询语句,隐藏复杂度更可怕。有很多漂亮的orm框架,比如java界曾经非常流行的hibernate,功能非常强大,社区也很火,但实际在生产中使用的人却很少,反倒是一些简单、轻量的被大规模应用了。而且互联网应用,对性能的要求较高,因此对sql的控制也需要更直接和精细。很多互联网公司也不推荐使用外键,因为db往往是瓶颈,关系的维护可以在应用服务器做,所以orm框架对应关系的定义不一定能用得上。

node社区有typeorm,sequelizejs等优秀的orm框架,个人其实并不喜欢用。我觉得比较好的是egg mysql插件所使用的ali-rds。它虽然简单,却能满足我大部分的需求。所以我们需要的是一个好用的mysql client,而不是orm。我也造了一个类似的轮子bsql,我希望api的设计更加接近sql的语意。目前第一个版本还比较简单,核心接口已经实现,还在迭代,欢迎关注。下面是user.dao的示例。

import { Injectable } from '@nestjs/common';
import { BsqlClient } from 'bsql';
import { UserPo, UserAddDto } from './user.types';
@Injectable()
export class UserDao {
    constructor(
        private readonly db: BsqlClient,
    ) { }
    /**
     * 添加用户
     * @param userAddDto
     */
    async addUser(userAddDto: UserAddDto): Promise<number> {
        const result = await this.db.insertInto('user').values([userAddDto]);
        return result.insertId;
    }
    /**
     * 查询用户列表
     * @param limit
     * @param offset
     */
    async listUsers(limit: number, offset: number): Promise<UserPo[]> {
        return this.db.select<UserPo>('*').from('user').limit(limit).offset(offset);
    }
    /**
     * 查询单个用户
     * @param id
     */
    async getUserById(id: number): Promise<UserPo> {
        const [user] = await this.db.select<UserPo>('*').from('user').where({ id }).limit(1);
        return user;
    }
}

从广义的角度看,dao层很像公式“程序=数据结构+算法”中的数据结构。“数据结构”的实现直接关系到上层的“算法”(业务逻辑)。

service层

service位于dao之上,使用dao提供的接口,也可以调用其它service。service层也比较简单,主要是弄清其职责和边界。

1、实现业务逻辑。
service负责业务逻辑这点毋庸置疑,核心是如何将业务逻辑抽象成接口及其粒度。service层应该尽量提供功能相对单一的基础方法,更多的场景和变化可以在controller层实现。这样设计有利于service层的复用和稳定。

2、处理异常。
service应该合理的捕获异常并将其转化成业务异常,因为service层是业务逻辑层,他的调用方更关心业务逻辑进行到哪一步了,而不是一些系统异常。

在实现上,可以定义一个business.exception.ts,里面包含常见的业务异常。当遇到业务逻辑执行不下去的问题时,抛出即可,调用方既能根据异常的类型采取行动。

// common/business.exception.ts
/**
 * 业务异常
 */
export class BusinessException {
    constructor(
        private readonly code: number,
        private readonly message: string,
        private readonly detail?: string,
    ) { }
}
/**
 * 参数异常
 */
export class ParamException extends BusinessException {
    constructor(message: string = '参数错误', detail?: string) {
        super(400, message, detail);
    }
}
/**
 * 权限异常
 */
export class AuthException extends BusinessException {
    constructor(message: string = '无权访问', detail?: string) {
        super(403, message, detail);
    }
}

对于业务异常,还需要一个兜底的地方全局捕获,因为不是每个调用方都会捕获并处理异常,兜底之后就可以记录日志(方便排查问题)同时给与一些友好的返回。在nest中统一捕获异常是定义一个全局filter,代码如下:

// common/business-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost } from '@nestjs/common';
import { BusinessException } from './business.exception';

/**
 * 业务异常统一处理
 */
@Catch(BusinessException)
export class BusinessExceptionFilter implements ExceptionFilter {
    catch(exception: BusinessException, host: ArgumentsHost) {
        const ctx = host.switchToHttp();
        const response = ctx.getResponse();
        response.json({ code: exception.code, message: exception.message });
        console.error(// tslint:disable-line
            'BusinessException code:%s message:%s \n%s',
            exception.code,
            exception.message,
            exception.detail);
    }
}
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { BusinessExceptionFilter } from './common/business-exception.filter';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // 注册为全局filter
  app.useGlobalFilters(new BusinessExceptionFilter());
  await app.listen(3000);
}
bootstrap();

3、参数校验。
dao层设计很简单,几乎不做参数校验,同时dao也一般不会开放给外部直接调用,而是开放service。所以service层应该做好参数校验,起到保护的作用。

4、事务控制。
dao层可以针对单个的持久化做事物控制,粒度比较小,而基于业务原则的事物处理就应该在service层。nest目前貌似没有在service层提供事务的支持。接下来我准备做个装饰器,在service层提供数据库本地事物的支持。分布式事务比较复杂,有专门的方法,后面有机会再介绍。

controller层

controller位于最上层,和外部系统打交道。把这层叫做“业务场景层”可能更贴切一点,它的职责是通过service提供的服务,实现某个特定的业务场景,并以http、rpc等方式暴露给外部调用。

1、聚合参数
前端传参方式有多种:query、body、param。有时搞不清楚到底应该从哪区,很不方便。我一般是自定义一个@Param()装饰器,把这几种参数对象聚合到一个。实现和使用方式如下:

// common/param.ts
import { createParamDecorator } from '@nestjs/common';

export const Param = createParamDecorator((data, req) => {
    const param = { ...req.query, ...req.body, ...req.param };
    return data ? param[data] : param;
});

// user/user.controller.ts
import { All, Controller } from '@nestjs/common';
import { UserService } from './user.service';
import { UserAddDto } from './user.types';
import { Param } from '../common/param';

@Controller('api/user')
export class UserController {
    constructor(private readonly userService: UserService) { }

    @All('add')
    async addUser(@Param() user: UserAddDto) {
        return this.userService.addUser(user);
    }

    @All('list')
    async listUsers(
        @Param('pageNo') pageNo: number = 1,
        @Param('pageSize') pageSize: number = 20) {
        return this.userService.listUsers(pageNo, pageSize);
    }
}

2、统一返回结构
一个api调用,往往都有个固定的结构,比如有状态码和数据。可以将controller的返回包装一层,省去一部分样板代码。下面是用Interceptor的一种实现:

// common/result.ts
import { Injectable, NestInterceptor, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

export interface Response<T> {
    data: T;
    code: number;
    message: string;
}

@Injectable()
export class ResultInterceptor<T>
    implements NestInterceptor<T, Response<T>> {
    intercept(
        context: ExecutionContext,
        call$: Observable<T>,
    ): Observable<Response<T>> {
        return call$.pipe(map(data => ({ code: 200, data, message: 'success' })));
    }
}

所有的返回将会包裹在如下的结构中:
clipboard.png

3、参数校验还是留给service层吧
nest提供了一套针对请求参数的校验机制,功能很强大。但使用起来会稍微繁琐一点,实际上也不会有太多复杂的参数校验。个人觉得参数校验可以统一留给service,assert库可能就把这个事情搞定了。

小结

本文讲的都是一些很小的点,大多是既有的理论。这些东西不想清楚,写代码时就会非常难受。大家可以把这里当做一个规范建议,希望能提供一些参考价值。

上一篇:nestjs后端开发实战(一)——依赖注入

查看原文

赞 16 收藏 8 评论 2

JerryC 赞了文章 · 2018-09-30

webpack启动代码源码解读

欢迎关注我的公众号睿Talk,获取我最新的文章:
clipboard.png

一、前言

虽然每天都在用webpack,但一直觉得隔着一层神秘的面纱,对它的工作原理一直似懂非懂。它是如何用原生JS实现模块间的依赖管理的呢?对于按需加载的模块,它是通过什么方式动态获取的?打包完成后那一堆/******/开头的代码是用来干什么的?本文将围绕以上3个问题,对照着源码给出解答。

如果你对webpack的配置调优感兴趣,可以看看我之前写的这篇文章:webpack调优总结

二、模块管理

先写一个简单的JS文件,看看webpack打包后会是什么样子:

// main.js
console.log('Hello Dickens');

// webpack.config.js
const path = require('path');
module.exports = {
  entry: './main.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist')
  }
};

在当前目录下运行webpack,会在dist目录下面生成打包好的bundle.js文件。去掉不必要的干扰后,核心代码如下:

// webpack启动代码
(function (modules) { 
    // 模块缓存对象
    var installedModules = {};

    // webpack实现的require函数
    function __webpack_require__(moduleId) {
        // 检查缓存对象,看模块是否加载过
        if (installedModules[moduleId]) {
            return installedModules[moduleId].exports;
        }

        // 创建一个新的模块缓存,再存入缓存对象
        var module = installedModules[moduleId] = {
            i: moduleId,
            l: false,
            exports: {}
        };

        // 执行模块代码
        modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
        
        // 将模块标识为已加载
        module.l = true;

        // 返回export的内容
        return module.exports;
    }

    ...

    // 加载入口模块
    return __webpack_require__(__webpack_require__.s = 0);
})
([
    /* 0 */
    (function (module, exports) {
        console.log('Hello Dickens');
    })
]);

代码是一个立即执行函数,参数modules是由各个模块组成的数组,本例子只有一个编号为0的模块,由一个函数包裹着,注入了moduleexports2个变量(本例没用到)。

核心代码是__webpack_require__这个函数,它的功能是根据传入的模块id,返回模块export的内容。模块id由webpack根据文件的依赖关系自动生成,是一个从0开始递增的数字,入口文件的id为0。所有的模块都会被webpack用一个函数包裹,按照顺序存入上面提到的数组实参当中。

模块export的内容会被缓存在installedModules中。当获取模块内容的时候,如果已经加载过,则直接从缓存返回,否则根据id从modules形参中取出模块内容并执行,同时将结果保存到缓存对象当中。缓存对象数据结构如下:

模块缓存对象

我们再添加一个文件,在入口文件处导入,再来看看生成的启动文件是怎样的。

// main.js
import logger from './logger';

console.log('Hello Dickens');
logger();

//logger.js
export default function log() {
    console.log('Log from logger');
}

启动文件的模块数组:

[
    /* 0 */
    (function (module, __webpack_exports__, __webpack_require__) {

        "use strict";
        Object.defineProperty(__webpack_exports__, "__esModule", {
            value: true
        });
        /* harmony import */
        var __WEBPACK_IMPORTED_MODULE_0__logger__ = __webpack_require__(1);

        console.log('Hello Dickens');

        Object(__WEBPACK_IMPORTED_MODULE_0__logger__["a" /* default */ ])();
    }),
    /* 1 */
    (function (module, __webpack_exports__, __webpack_require__) {
    
        "use strict";
        /* harmony export (immutable) */
        __webpack_exports__["a"] = log;

        function log() {
            console.log('Log from logger');
        }
    })
]

可以看到现在有2个模块,每个模块的包裹函数都传入了module, __webpack_exports__, __webpack_require__三个参数,它们是通过上文提到的__webpack_require__注入的:

// 执行模块代码
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

执行的结果也保存在缓存对象中了。

执行流程如下图所示:
同步

三、按需加载

再对代码进行改造,来研究webpack是如何实现动态加载的:

// main.js
console.log('Hello Dickens');

import('./logger').then(logger => {
    logger.default();
});

logger文件保持不变,编译后比之前多出了1个chunk。
clipboard.png

bundle_asy的内容如下:

(function (modules) {
    // 加载成功后的JSONP回调函数
    var parentJsonpFunction = window["webpackJsonp"];

    // 加载成功后的JSONP回调函数
    window["webpackJsonp"] = function webpackJsonpCallback(chunkIds, moreModules, executeModules) {
        var moduleId, chunkId, i = 0,
            resolves = [],
            result;

        for (; i < chunkIds.length; i++) {
            chunkId = chunkIds[i];
            
            // installedChunks[chunkId]不为0且不为undefined,将其放入加载成功数组
            if (installedChunks[chunkId]) {
                // promise的resolve
                resolves.push(installedChunks[chunkId][0]);
            }
            
            // 标记模块加载完成
            installedChunks[chunkId] = 0;
        }

        // 将动态加载的模块添加到modules数组中,以供后续的require使用
        for (moduleId in moreModules) {
            if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
                modules[moduleId] = moreModules[moduleId];
            }
        }

        if (parentJsonpFunction) parentJsonpFunction(chunkIds, moreModules, executeModules);

        while (resolves.length) {
            resolves.shift()();
        }
    };

    // 模块缓存对象
    var installedModules = {};

    // 记录正在加载和已经加载的chunk的对象,0表示已经加载成功
    // 1是当前模块的编号,已加载完成
    var installedChunks = {
        1: 0
    };

    // require函数,跟上面的一样
    function __webpack_require__(moduleId) {
        if (installedModules[moduleId]) {
            return installedModules[moduleId].exports;
        }

        var module = installedModules[moduleId] = {
            i: moduleId,
            l: false,
            exports: {}
        };

        modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

        module.l = true;

        return module.exports;
    }

    // 按需加载,通过动态添加script标签实现
    __webpack_require__.e = function requireEnsure(chunkId) {
        var installedChunkData = installedChunks[chunkId];

        // chunk已经加载成功
        if (installedChunkData === 0) {
            return new Promise(function (resolve) {
                resolve();
            });
        }

        // 加载中,返回之前创建的promise(数组下标为2)
        if (installedChunkData) {
            return installedChunkData[2];
        }

        // 将promise相关函数保持到installedChunks中方便后续resolve或reject
        var promise = new Promise(function (resolve, reject) {
            installedChunkData = installedChunks[chunkId] = [resolve, reject];
        });
        installedChunkData[2] = promise;

        // 启动chunk的异步加载
        var head = document.getElementsByTagName('head')[0];
        var script = document.createElement('script');
        script.type = 'text/javascript';
        script.charset = 'utf-8';
        script.async = true;
        script.timeout = 120000;
        if (__webpack_require__.nc) {
            script.setAttribute("nonce", __webpack_require__.nc);
        }
        script.src = __webpack_require__.p + "" + chunkId + ".bundle_async.js";
        script.onerror = script.onload = onScriptComplete;
        var timeout = setTimeout(onScriptComplete, 120000);

        function onScriptComplete() {
            script.onerror = script.onload = null;
            
            clearTimeout(timeout);
            
            var chunk = installedChunks[chunkId];
            
            // 正常的流程,模块加载完后会调用webpackJsonp方法,将chunk置为0
            // 如果不为0,则可能是加载失败或者超时
            if (chunk !== 0) {
                if (chunk) {
                    // 调用promise的reject
                    chunk[1](new Error('Loading chunk ' + chunkId + ' failed.'));
                }
                installedChunks[chunkId] = undefined;
            }
        };
        
        head.appendChild(script);
        
        return promise;
    };

    ...
    
    // 加载入口模块
    return __webpack_require__(__webpack_require__.s = 0);
})
([
    /* 0 */
    (function (module, exports, __webpack_require__) {

        console.log('Hello Dickens');

        // promise resolve后,会指定加载哪个模块
        __webpack_require__.e /* import() */(0)
            .then(__webpack_require__.bind(null, 1))
            .then(logger => {
                logger.default();
            });
    })
]);

这里用户记录异步模块加载状态的对象installedChunks的数据结构如下:

chunk

当chunk加载完成后,对应的值是0。在加载过程中,对应的值是一个数组,数组内保存了promise的相关信息。

挂在到window下面的webpackJsonp函数是动态加载模块代码下载后的回调,它会通知webpack模块下载完成并将模块加入到modules当中。

__webpack_require__.e函数是动态加载的核心实现,它通过动态创建一个script标签来实现代码的异步加载。加载开始前会创建一个promise存到installedChunks对象当中,加载成功则调用resolve,失败则调用reject。resolve后不会传入模块本身,而是通过__webpack_require__来加载模块内容,require的模块id由webpack来生成:

__webpack_require__.e /* import() */(0)
    .then(__webpack_require__.bind(null, 1))
    .then(logger => {
        logger.default();
    });

这里之所以要加上default是因为遇到按需加载时,如果使用的是ES Module,webpack会将export default编译成__webpack_exports__对象的default属性(感谢@MrCanJu的指正)。详细请看动态加载的chunk的代码,0.bundle_asy的内容如下:

webpackJsonp([0], [
    /* 0 */
    ,
    /* 1 */
    (function (module, __webpack_exports__, __webpack_require__) {

        "use strict";
        Object.defineProperty(__webpack_exports__, "__esModule", {
            value: true
        });
        /* harmony export (immutable) */
        __webpack_exports__["default"] = log;

        function log() {
            console.log('Log from logger');
        }
    })
]);

代码非常好理解,加载成功后立即调用上文提到的webpackJsonp方法,将chunkId和模块内容传入。这里要分清2个概念,一个是chunkId,一个moduleId。这个chunk的chunkId是0,里面只包含一个module,moduleId是1。一个chunk里面可以包含多个module。

执行流程如下图所示:

异步

四、总结

本文通过分析webpack生成的启动代码,讲解了webpack是如何实现模块管理和动态加载的,希望对你有所帮助。

如果你对webpack的配置调优感兴趣,可以看看我之前写的这篇文章:webpack调优总结

查看原文

赞 60 收藏 38 评论 10