JerryC

JerryC 查看完整档案

深圳编辑  |  填写毕业院校腾讯 - Alloyteam  |  高级前端工程师 编辑 huang-jerryc.com 编辑
编辑

Peace of mind, Code of enjoy

个人动态

JerryC 发布了文章 · 3月21日

微信小程序路由实战

欢迎来到我博客阅读:BlueSun - 微信小程序路由实战

0. 目录

1. 前言

在微信小程序由一个 App()实例,和众多Page()组成。而在小程序中所有页面的路由全部由框架进行管理,框架以栈的形式维护了所有页面,然后提供了以下 API 来进行路由之间的跳转:

  1. wx.navigateTo
  2. wx.redirectTo
  3. wx.navigateBack
  4. wx.switchTab
  5. wx.reLaunch

但是,对于一个企业应用,把这些问题留给了开发者:

  1. 原生 API 使用了 Callback 的函数实现形式,与我们现代普遍的 Promiseasync/await 存在 gap。
  2. 基于小程序路由的设计,暴露给外部的是真实路由(如扫码,公众号链接等方式),对后续项目重构留下历史包袱。
  3. 小程序页面栈最多十层, 在超过十层后 wx.navigateTo 失效,需要开发者判断使用 wx.redirectTo 或其他API
  4. 小程序页面栈存在一种特殊的页面:Tab 页面,需要使用 wx.switchTab 才能跳转。需要开发者主动判断,不方便后期改动 Tab 页面属性。
  5. 额外的,对于小程序码,要使用无数量限制 API wxacode.getUnlimited ,存在参数长度限制32位以内。需要开发者自行解决。

而本文,期望能对这若干问题,逐个提供解决方案。

2. 智能路由跳转 — Navigator 模块

在这里我们一起解决:

  1. 原生 API 非 Promsie
  2. 页面栈突破十层时特殊处理
  3. 特殊页面 Tab 的跳转处理

我们的思路是,希望能设计一种逻辑,根据场景来自动判断使用哪个微信路由 API,然后对外只提供一个函数,例如:

gotoPage('/pages/goods/index')

具体逻辑如下:

  1. 当跳转的路由为小程序 tab 页面时,则使用 wx.switchTab
  2. 当页面栈达到 10 层之后,如果要跳转的页面在页面栈中,使用 wx.navigateBack({ delta: X }) 出栈到目标页面。
  3. 当页面栈达到 10 层之后,目标页面不存在页面栈中,使用 wx.redirectTo 替换栈顶页面。
  4. 其他情况使用 wx.navigateTo

顺带的,我们把这个函数以 Promise 形式实现,以及支持参数作为 object传入,例如:

gotoPage('/pages/goods/index', { name: 'jc' }).then(...).catch(...);

大部分场景下,只要使用gotoPage就能满足。

那肯定也会有特定的情况,需要显式的指定使用 navigateTo/switchTab/redirectTo/navigateBack的哪一个。

那么我们也按照类似的实现,满足相同模式的 API

navigateTo('/pages/goods/index', { name: 'jc' }).then(...).catch(...);
switchTab('/pages/goods/index', { name: 'jc' }).then(...).catch(...);
redirectTo('/pages/goods/index', { name: 'jc' }).then(...).catch(...);
navigateBack('/pages/goods/index', { name: 'jc' }).then(...).catch(...);

这些函数都可以内聚到同一个模块,我们称其为:Navigator

const navigator = new Navigator();
navigator.gotoPage(...);
navigator.navigateTo(...);
navigator.switchTab(...);
navigator.redirectTo(...);
navigator.navigateBack(...);

模块设计:

navigator-class

3. 虚拟路由策略 — Router 模块

在这里,我们解决:

  1. 对外暴露了真实路由,导致历史包袱沉重的问题。

在许多应用开发中,我们经常需要把某种模式匹配到的所有路由,全都映射到同个页面中去。
例如,我们有一个 Goods 页面,对于所有 ID 各不相同的商品,都要使用这个页面来承载。

那么在代码层面上,期望能实现这样的调用方式:

// 创建路由实例
const router = new Router();

// 注册路由
router.register({
  path: '/goods/:id', // 虚拟路由
  route: '/pages/goods/index', // 真实路由
});

// 跳转到 /pages/goods/index,参数: onLoad(options) 的 options = { id: '123' }
router.gotoPage('/goods/123');

// 跳转到 /pages/goods/index,参数: onLoad(options) 的 options = { id: '456' }
router.gotoPage('/goods/456');

Class Router 的核心逻辑是完成:

  1. 路由的注册,完成「虚拟路径」和「真实路径」关系的存储。
  2. 满足「虚拟路径」到「真实路径」的转换,并且识别「动态路径参数」(dynamic segment)。
  3. 路由跳转。

对于「路由的注册」,我们在其内部存储一个 map 就能完成。

而对于「路径的转换」, vue-router 有类似的实现,通过其源码发现,内部是使用 path-to-regexp 作为路径匹配引擎,我们可以拿来用之。

然后对于「路由的跳转」,我们可以直接复用上面提到的 Navigator 模块,通过输入真实路径,来完成路由的跳转。

模块设计:

route-class

其中:

  1. RouteMatcher:提供动态路由参数匹配功能,内部使用 path-to-regexp 作为路径匹配引擎。
  2. Route: 为每个路径创建路由器,存储每个路由的虚拟路径和真实路由的关系。
  3. Router:整合内部各模块,对外提供统一且优雅的调用方式。

4. 落地中转策略 — LandTransfer 模块

在这里,我们解决:

  1. 小程序扫码、公众号链接等场景下的落地页统一。
  2. 小程序码,对于无限量API wxacode.getUnlimited ,突破参数32位长度限制。

4.1. 对于要解决的第一个问题:统一的落地页

我们把如:扫小程序码、公众号菜单、公众号文章等方式打开小程序某个页面的路径称为「外部路由」。

根据小程序的设计,暴露给外部的连接是真实的页面路径,如:/pages/home/index,该设计在实践中存在的弊端:各个落地页分散,后期修改真实文件路径难度大。

「中长生命周期」 产品中,随着产品的迭代,我们难免会遇到项目的重构。如果分发出去的都是没经过处理的真实路径的话,我们重构时就会束手束脚,要做很多的兼容操作。因为你不知道,分发出去的小程序二维码, 有多少被打印到实体物料中。

那么,「虚拟路由」+「落地中转」 的策略就显得基本且重要了。

「虚拟路由」的功能,Router 模块给我们提供了支持了,我们还需要对外提供一个统一的落地页面,让它来完成对内部路由的中转。

基本逻辑:

  1. 分发出去的真实路由,指向到唯一的落地页面,如:$LAND_PAGE: /pages/land-page/index
  2. 由这个落地页面,进行内部路由的重定向转发,通过接收 参数,如:path=/user&name=jc&age=18

普通模式

在代码层面上,我们希望能实现这样的使用:

// /pages/land-page/index.ts

const landTransfer = new LandTransfer(landTransferOptions);

Page({
  onLoad(options) {
      landTransfer
        .run(options)
        .then(() => {...})
        .catch(() => {...});
  }
});

然后针对 TS,我们还可以使用装饰器版本,更加简便:

import { landTransferDecorator } from 'wxapp-router';

Page({
  @landTransferDecorator(landTransferOptions)
  onLoad(options) {
    // ...
  },
});

4.2. 对于第二个要解决的问题:短链参数

微信小程序主要提供了两个接口去生成小程序码:

  1. wxacode.get: 获取小程序码,适用于需要的码数量较少的业务场景。通过该接口生成的小程序码,永久有效,数量限制为 100,000
  2. wxacode.getUnlimited: 获取小程序码,适用于需要的码数量极多的业务场景。通过该接口生成的小程序码,永久有效,数量暂无限制。

第一种方式,wxacode.get 数量限制为 10w 个,虽然量很大了,绝大多数的小程序可能用不到这个量。

但如果我们运营的是一个中大型电商小程序的话,假如:1w 种商品 x 10 种商品规格,那就会超过这个数量。到时候再进行改造,就困难了。

所以,如果抱着是运营一个 「中长生命周期」 的产品的话,我们会使用第二种方式:wxacode.getUnlimited

不尽人意的是,虽然它没有数量限制,但是对参数会有 32 个字符的限制,显然是不够用的(一个 uuid 就 32 字符了)。

对于这种情况,我们可以使用「短链参数」的形式解决,由于wxacode.getUnlimited 会通过 scene字段作为 query 参数传递给小程序的,那么我们可以通过 scene参数来实现短链服务,这需要后端配合。

前后端交互如下:

Scene短链模式

  1. 当小程序需要生成小程序码的时候,请求后端提供的接口,例如:/api/encodeShortParams
  2. 后端把内容转换为 32 字符内的字符串,存储到数据库中。
  3. 后端通过 wxacode.getUnlimited 接口,以短链字符串作为 scene的值,以商定好的统一落地页 $LAND_PAGE作为 page值,生成小程序码。
  4. 当通过小程序码进入小程序,小程序获取到 scene参数,请求后端提供的接口,例如:/api/decodeShrotParams
  5. 小程序理解内容,跳转到目标页面中去。

而前端对于统一落地页的逻辑处理,我们只需要在第一个问题的基础上,增加一个转换短链参数内容的逻辑就行了:

短链模式

代码层面上,我们我们只需要多定义转换短链参数的方式:convertScenePrams

// in /pages/land-page/index.js
import { landTransferDecorator } from 'wxapp-router';

const landTransferOptions = {
  // 此处接收 onLoad(options) 中的 options.scene
  convertSceneParams: (sceneParams) => {
    return API.convertScene({ sceneParams }).then((content) => {
      // 假如后端存的是 JSON 字符串,前端decode
      // 要求 content = { path: '/home', a: 1, b:2 }
      return JSON.parse(content);
    });
  },
};

Page({
  @landTransferDecorator(landTransferOptions)
  onLoad(options) {
    // ...
  },
});

而其中的 API.convertScene 就对接服务端提供 HTTP 接口服务来完成。

4.3. LandTransfer 模块设计

land-transfer

5. 更好的开发体验

5.1. Typescript + Router

对于小程序内部的路由跳转,我们除了指定一个字符串的路由,我们是否也可以通过链式调用,像调用函数那样去跳转页面呢?类似这样;

routes.pages.user.go({ name: 'jc' });

这样做的好处是:

  1. 更自然的调用方式。
  2. 能结合 TS,来做到类型提示和联想。

由于事先 wxapp-router 并不知道开发者需要注册的路由是什么样的,所以路由的 TS 声明文件,需要开发者来定义。

例如,我们在项目中维护一份路由文件:

// config/routes.ts

// 创建路由实例
const router = new Router();

const routesConfig = [{
  path: '/user',
  route: '/pages/user/index',
}, {
  path: '/goods',
  route: '/pages/goods/index',
}];

type RoutesType {
  paegs: {
    user: Route<{name: string}>,
    goods: Route,
  }
}

// 注册路由
router.batchRegister(routesConfig);

// 获取 routes
const routes: RoutesType = router.getRoutes();

export default routes;

然后在别的地方使用它:

import routes from './routes.ts';

routes.pages.user.go({ name: 'jc' });

5.2. 智能生成路由配置

如果路由变多的时候,我们还需要对每个路由手动去编写 RoutesType 的话,就有点难受了。

在小程序中,我们把正式路由都配置到 app.json ,那么在遵循既定的项目结构情况下,我们可以通过自动构建,完成大部分工作,例如:

  1. 智能注册路由
  2. 智能识别页面入参声明

5.3. 自定义组件跳转

以上都是脚本层面的使用,小程序中还有 wxml, 我们希望能在有个组件快速使用:

<Router path="/pageA" query="{{pageAQuery}}"></Router>
<Router path="/pageB" query="{{pageBQuery}}" type="redirectTo"></Router>
<Router path="/pageC/katy"></Router>

那么,实现一个自定义组件,然后把 Router模块包装一下,问题就不大了。

示例代码:

// components/router.wxml

<view class="wxapp-router" bind:tap="gotoPage">
    <slot />
</view>
// components/router.ts

Component({
    properties: {
        path: String,
        type: {
            type: String,
            value: 'gotoPage'
        },
        route: String,
        query: Object,
        delta: Number,
        setData: Object,
    },

    methods: {
        gotoPage(event) {
            const router = getApp().router;
            const { path, route, type, query} = this.data;
            const toPath = route || path;

            if (['gotoPage', 'navigateTo', 'switchTab', 'redirectTo'].includes(type)) {
                (router as any)[type](toPath, query);
            }

            if (type === 'navigateBack') {
                const { delta, setData } = this.data;
                router.navigateBack({ delta }, { setData })
            }
        }
    }
})

6. 整体架构图

最后,我们来整体回顾一下各模块的设计

架构设计

  1. Navigator:封装微信原生路由 API,提供智能跳转策略。
  2. LandTransfer:提供落地页中转策略。
  3. RouteMatcher:提供动态路由参数匹配功能。
  4. Route: 为每个路径创建路由器。
  5. Router:整合内部各模块,对外提供优雅的调用方式。
  6. Logger:内部日志器。
  7. Path-to-regexp: 开源社区的路由匹配引擎。

7. 最后的最后

鉴于写过很多的实战类的文章,会有不少同学想要到整体的示例代码,这次我就索性写了一个工具,Enjoy it!

wxapp-router: 🛵 The router for Wechat Miniprogram

查看原文

赞 7 收藏 6 评论 0

JerryC 赞了文章 · 3月1日

2016年里做前端是怎样一种体验

本文翻译自how-it-feels-to-learn-javascript-in-2016,从属于笔者的Web Frontend Introduction And Best Practices:前端入门与最佳实践系列文章。
最近我女朋友也打算开始学习前端的一些知识,不过她目前的认知水平还停留在DOM+jQuery盛行的阶段,正好借翻译这篇文章之机,跟她讲讲2016年的前端是个什么状态。

问:最近我接手了一个新的Web项目,不过老实说我已经好久没碰过这方面的代码了。听说前端的技术栈已经发生了极大的变革,不知道你现在是不是仍然处于最前沿的开发者阵列?
答:准确来说,过去俗称的写网页的,现在应该叫做Front End Engineer,我确实属于这所谓的前端工程师。并且我才从JSConf与ReactConf面基回来,因此我觉得我觉得我还是了解目前Web前端领域最新的面貌的。
问:不错不错,我的需求其实也不复杂,就是从后端提供的REST风格的EndPoint来获取用户活动数据并且将其展示在前端界面上。并且需要以列表形式展示,同时,列表要支持筛选排序等操作,对了,还要保证前端数据和服务端保持一致。按照我现在的理解,我打算用jQuery来抓取与展现数据,你觉得咋样?
答:不不不,现在估计已经没多少人使用jQuery了吧。你可以试试React,毕竟这是2016年了啊。
问:额,好吧,那啥是React啊?
答:这是个非常不错的源自Facebook的前端库,它能够帮你便捷地响应界面事件,同时保证项目层级的可控性与还说得过去的性能。
问:不错不错,那我是不是就可以用React来展示数据了呢?
答:话是这么说没错,不过你需要添加React与React DOM依赖项到你的页面中去。
问:等等,React不是一个库吗?为啥要添加两个依赖呢?
答:不要急,前者是React的核心库,后面呢算是DOM操作的辅助库,这样就能让你用JSX来描述你的界面布局了。
问:JSX?啥是JSX?
答:JSX是一个类似于XML的JavaScript语法扩展,它是另一种描述DOM的方式,可以认为是HTML的替代品。
问:等等,HTML咋啦?
答:都2016了,直接用HTML早就过时了。
问:好吧,那是不是我把两个库添加到项目中我就可以使用React了?
答:额,还要一些小的工具,你需要添加Babel到你的项目中,这样你就能用了。
问:又是一个库?Babel又是什么鬼?
答:你可以把Babel认为是一个转译工具,可以将某个特定版本的JavaScript转译为任意版本的JavaScript。你可以选择不使用Babel,不过那也就意味着你只能用烦人的ES5来编写你的项目了。不过既然都是2016了,我建议你还是使用最新的ES2016+语法吧。
问:ES5?ES2016+?我已经迷茫了,ES5,ES2016+又是啥?
答:ES5是ECMAScript 2015的缩写,也是现在被绝大部分浏览器所支持的JavaScript语法。
问:ECMAScript?
答:是的,你应该知道JavaScript最早于1995年提出,而后在1999年第一个正式版本定稿。之后的十数年里JavaScript的发展一直很凌乱,不过经过七个版本之后已经逐步清晰了。
问:7个版本?那么ES5与ES2016+又是第几个版本呢?
答:是的,分别指第五个版本与第七个版本。
问:等等,那第六个版本呢?
答:你说ES6?估计我刚才没有讲明白,ECMAScript的每个版本都是向前兼容的,当你使用ES2016+的时候也就意味着你在使用之前所有版本的所有特性啦。
问:原来是这样啊,那为啥一定要用ES2016+而不是ES6呢?
答:是的,你可以使用ES6,不过如果你要使用async与await这些特性,你就要去用ES2016+了。否则你就还不得不去使用ES6的Generator来编写异步代码了。
问:我现在彻底迷糊了,我只是想简单地从服务端加载些数据而已,之前只需要从CDN加载下jQuery的依赖库,然后用Ajax方法来获取数据即可,为啥我现在不能这么做呢?
答:别傻了,每个人都知道一味使用jQuery的后果就是让你的代码变得一团乱麻,这都2016了,没人再想去面对这种头疼的代码了。
问:你说的是有道理,那现在我是不是就把这三个库加载进来,然后用HTML的Table来展示这些数据?
答:嗯,你可以选择一个模块打包工具将这三个依赖库打包到一个文件中。
问:额,啥是模块打包工具啊?
答:这个名词在不同的环境下指代也不同,不过在Web开发中我们一般将支持AMD与CommonJS的工具称为模块打包工具。
问:AMD与CommonJS又是?
答:它们是用于描述JavaScript库与类之间交互的接口标准,你有听过exports与requires吗?你可以根据AMD或者CommonJS的规范来定义多个JavaScript文件,然后用类似于Browserify的工具来打包它们。
问:原来是这样,那Browserify是啥呢?
答:Browserify最早是为了避免人们把自己的依赖一股脑放到NPM Registry中构建的,它最主要的功能就是允许人们将遵循CommonJS规范的模块打包到一个文件中。
问:NPM Registry?
答:这是一个很大的在线仓库,允许人们将代码与依赖以模块方式打包发布。
问:就像CDN一样?
答:还是有很大差异的,它更像一个允许人们发布与下载依赖库的中心仓库。
问:哦,我懂了,就像Bower一样啊。
答:对哒,不过2016年了,同样没啥人用Bower了。
问:嗯嗯,那我这时候应该从npm库中下载依赖了是吧?
答:是的,譬如如果你要用React的话,你可以直接用Npm命令来安装React,然后导入到你的项目中,现在绝大部分主流的JavaScript库都支持这种方式了。
问:嗯嗯,就像Angular一样啊。
答:不过Angular也是2015年的流行了,现在像VueJS或者RxJS这样的才是小鲜肉,你想去学习它们吗?
问:不急不急,我们还是先多聊聊React吧,贪多嚼不烂。我还想确定下,是不是我从npm下载了React然后用Browserify打包就可以了?
答:是的。
问:好的,不过每次都要下载一大堆依赖然后打包,看起来好麻烦啊。
答:是的,不过你可以使用像Grunt或者Gulp或者Broccoli这样的任务管理工具来自动运行Browserify。对了,你还可以用Mimosa。
问:Grunt?Gulp?Broccoli?Mimosa?我们到底在讨论啥?
答:不方,我们在讨论任务管理工具,不过同样的,这些工具也是属于2015年的弄潮儿。现在我们流行使用Webpack咯。
问:Makefiles?听起来有点像是一个C或者C++项目啊。
答:没错,不过很明显Web的演变之路就是把所有事情弄复杂,然后再回归到最基础的方式。估计不出你点你就要在Web中写汇编代码了。
问:额,你刚才好像提到了Webpack?
答:是的,这是一个兼顾了模块打包工具与任务运行器的打包工具,有点像Browserify的升级版本。
问:嗷嗷,这样啊,那你觉得哪个更好点呢?
答:这个因人而异了,不过我个人是更加偏好于Webpack,毕竟它不仅仅支持CommonJS规范,还支持ES6的模块规范。
问:好吧,我已经被CommonJS/ES6这些东西彻底搞乱了。
答:很多人都是这样,多了,你可能还要去了解下SystemJS。
问:天哪,又是一个新名词,啥是SystemJS呢?
答:不同于Browserify与Webpack 1.x,SystemJS是一个允许你将多个模块分封于多个文件的动态模块打包工具,而不是全部打包到一个大的文件中。
问:等等,不过我觉得按照网络优化规范我们应该将所有的库打包到一个文件中。
答:是的,不过HTTP/2快要来了,并发的HTTP请求已经不是梦。
问:额,那时候是不是就不需要添加React的依赖库了?
答:不一定,你可以将这些依赖库从CDN中加载进来,不过你还是需要引入Babel的吧。
问:额,我刚才好像说错了话。
答:是的,如果按照你所说的,你需要在生产环境下将所有的babel-core引入,这样会无端端增加很多额外的性能消耗。
问:好吧,那我到底应该怎么做呢?
答:我个人建议是用TypeScript+Webpack+SystemJS+Babel这一个组合。
问:TypeScript?我一直以为我们在说的是JavaScript!
答:是的,TypeScript是JavaScript的超集,基于ES6版本的一些封装。你应该还没忘记ES6吧?
问:我以为我们刚才说到的ES2016+就是ES6的超集了。为啥我们还需要TypeScript呢?
答:因为TypeScript允许我们以静态类型语言的方式编写JavaScript,从而减少运行时错误。都2016了,添加些强类型不是坏事。
问:原来TypeScript是做这个的啊!
答:是的,还有一个就是Facebook出品的Flow。
问:Flow又是啥?
答:Flow是Facebook出品的静态类型检测工具,基于函数式编程的OCaml构建。
问:OCamel?函数式编程?
答:你没听过吗?函数式编程?高阶函数?Currying?纯函数?
问:我一无所知。
答:好吧,那你只需要记得函数式编程在某些方面是优于OOP的,并且我们在2016年应该多多使用呦。
问:等等,我在大学就学过了OOP,我觉得挺好的啊。
答:是的,OOP确实还有很多可圈可点的地方,不过大家已经认识到了可变的状态太容易引发未知问题了,因此慢慢的所有人都在转向不可变数据与函数式编程。在前端领域我们可以用Rambda这样的库来在JavaScript中使用函数式编程了。
问:你是不是专门一字排开名词来了?Ramda又是啥?
答:当然不是啦,Rambda是类似于Lambda的库,源自David Chambers。
问:David Chambers?
答:David Chambers是个很优秀的程序员,他是Rambda的核心贡献者之一。如果你要学习函数式编程的话,你还应该关注下Erik Meijer。
问:Erik Meijer?
答:另一个函数式编程领域的大神与布道者。
问:好吧,还会让我们回到React的话题吧,我应该怎么使用React来抓取数据呢?
答:额,React只是用于展示数据的,它并不能够帮你抓取数据。
问:我的天啊,那我怎么来抓取数据呢?
答:你应该使用Fetch来从服务端获取数据。
问:Fetch?
答:是的,Fetch是浏览器原生基于XMLHttpRequests的封装。
问:那就是Ajax咯?
答:AJAX一般指仅仅使用XMLHttpRequests,而Fetch允许你基于Promise来使用Ajax,这样就能够避免Callback hell了。
问:Callback Hell?
答:是的,每次你向服务器发起某个异步请求的时候,你必须要添加一个异步回调函数来处理其响应,这样一层又一层地回调的嵌套就是所谓的Callback Hell了。
问:好吧,那Promise就是专门处理这个哩?
答:没错,你可以用Promise来替换传统的基于回调的异步函数调用方式,从而编写出更容易理解与测试的代码。
问:那我现在是不是直接使用Fetch就好了啊?
答:是啊,不过如果你想要在较老版本的浏览器中使用Fetch,你需要引入Fetch Polyfill,或者使用Request、Bluebird或者Axios。
问:来啊,互相伤害吧,你还是直接告诉我我还需要了解多少个库吧!
答:这可是JavaScript啊,可是有成千上万个库的。而且不少库还很大呢,譬如那个嵌了一张Guy Fieri图片的库。
问:你是说Guy Fieri?我听说过,那Bluebird、Request、Axios又是啥呢?
答:它们可以帮你执行XMLHttpRequests然后返回Promises对象。
问:难道jQuery的AJAX方法不是返回Promise吗?
答:请忘掉jQuery吧,用Fetch配合上Promise,或者async/await能够帮你构造合适的控制流。
问:这是你第三次提到Await了,这到底是个啥啊?
答:Await是ES7提供的关键字,能够帮你阻塞某个异步调用直到其返回,这样能够让你的控制流更加清晰,代码的可读性也能更上一层楼。你可以在Babel中添加stage-3 preset,或者添加syntax-async-functions以及transform-async-to-generator这两个插件。
问:好麻烦啊。
答:是啊,不过更麻烦的是你必须先预编译TypeScript代码,然后用Babel来转译await。
问:为啥?难道TypeScript中没有内置?
答:估计在下一个版本中会添加该支持,不过目前的1.7版本的TypeScript目标是ES6,因此如果你还想在浏览器中使用await,你必须要先把TypeScript编译为ES6,然后使用Babel转译为ES5。
问:我已经无话可说了。
答:好吧,其实你也不用想太多,首先你基于TypeScript进行编码,然后将所有使用Fetch的模块转译为ES6,然后再使用Babel的stage-3 preset来对await等进行Polyfill,最后使用SystemJS来完成加载。如果你打算使用Fetch的话,还可以使用Bluebird、Request或者Axios。
问:好,这样说就清晰多了,是不是这样我就达到我的目标了?
答:额,你的应用需要处理任何的状态变更吗?
问:我觉得不要把,我只是想展示数据。
答:那还行,否则的话你还需要了解Flux、Redux等等一系列的东西。
问:我不想再纠结于这些名词了,再强调一遍,我只是想展示数据罢了。
答:好吧,其实如果你只是想展示数据的话,你并不需要React,你只需要一个比较好的模板引擎罢了。
问:你在开玩笑?
答:不要着急,我只是告诉你你可以用到的东西。
问:停!
答:我的意思是,即使你仅仅打算用个模板引擎,还是建议使用下TypeScript+SystemJS+Babel。
问:好吧,那你还是推荐一个模板引擎吧!
答:有很多啊,你有对哪种比较熟悉吗?
问:唔,好久之前用了,记不得了。
答:jTemplates?jQote?PURE?
问:没听过,还有吗?
答:Transparency?JSRender?MarkupJS?KnockoutJS?
问:还有吗?
答:PlatesJS?jQuery-tmpl?Handlebars?
问:好像最后一个有点印象。
答:Mustache?underscore?
问:好像更晚一点的。
答:Jade?DustJS?
问:不。
答:DotJS?EJS?
问:不。
答:Nunjucks?ECT?
问:不。
答:Mah?Jade?
问:额,还不是。
答?难道是ES6原生的字符串模板引擎。
问:我估计,这货也需要ES6吧。
答:是啊。
问:需要Babel?
答:是啊。
问:是不是还要从npm下载核心模块?
答:是啊。
问:是不是还需要Browserify、Webpack或者类似于SystemJS这样的模块打包工具?
答:是啊。
问:除了Webpack,还需要引入任务管理器。
答:是啊。
问:我是不是还需要某个函数式编程语言,或者强类型语言?
答:是啊。
问:然后如果用到await的话,还需要引入Babel?
答:是啊。
问:然后就可以使用Fetch、Promise了吧?
答:别忘了Polyfill Fetch,Safari目前还不能原生支持Fetch。
问:是不是,学完这些,就OK了?
答:额,目前来看是的,不过估计过几年我们就需要用Elm或者WebAssembly咯~
问:我觉得,我还是乖乖去写后端的代码吧。
答:Python大法好!

查看原文

赞 12 收藏 79 评论 10

JerryC 发布了文章 · 1月28日

Module Seed: 一套优雅的 Github 工作流

Motivation

平时喜欢写一些 NPM 模块,写得多了,整理出一套工作流,解放一些重复的搭建工作。
如果你喜欢,请直接拿去用,也可以参照该项目的一些 Feature ,给你一些提示与帮助。

Feature

  1. 支持 Typescript
  2. 支持单元测试,与测试覆盖率
  3. 快速生成文档站点
  4. 接入 Circle CLI,构建、发包、文档站点一条龙服务
  5. 规范 ESLint + Prettier
  6. 快速生成 Change Log
  7. 生成同时支持 CommonJS 和 ES Module 的 NPM 包

Download

git clone git@github.com:JerryC8080/module-seed.git

Usage

1. Architecture

.
├── .circleci // CircleCI 脚本
│   ├── config.yml
├── coverage // 自动生成的测试覆盖率报告
├── docs  // 自动生成的文档
├── build  // 构建代码
│   ├── main  // 兼容 CommonJS
│   │   ├── index.d.ts
│   │   ├── index.js
│   │   └── lib
│   └── module  // 兼容 ES Module
│       ├── index.d.ts
│       ├── index.js
│       └── lib
├── src  // 源码
│   ├── index.ts
│   └── lib
│       ├── hello-world.spec.ts // 单元测试
│       └── hello-world.ts
├── CHANGELOG.md
├── LICENSE
├── README.md
├── package.json
├── tsconfig.json
└── tsconfig.module.json

2. Npm Script

ScriptDescription
build构建代码,生成 ./build 文件夹
fix快速格式化代码
test构建单元测试
watch:build动态构建代码,用于开发模式
watch:test动态构建单元测试,用于开发模式
cov构建单元测试覆盖率,生成 ./coverage 文件夹
doc构建文档站点,生成 ./docs 文件夹
doc:publish发布文档站点到 github pages
version强制以 patch 模式更新 version,如:v0.0.1 → v0.0.2

3. Coverage

通过运行 npm run cov,命令会构建单元测试,并且输出网页版本的测试报告:

open coverage/index.html

coverage

4. Docs

通过运行 npm run doc,会构建 TS API 文档,并且输出网页版本:

open docs/index.html

docs

5. CircleCI Config

本项目选择 CircleCI 来完成项目构建、发布 NPM、发布文档站点等自动化构建工作。

1. Add Repo to CircleCI

2. Test Coverage to Coveralls

如果想拥有一个这样的 Status: Coverage Status

需要把你的 repo 添加到 coveralls.io

然后,在 CircleCI 添加环境变量 COVERALLS_REPO_TOKEN

那么,每次 CircleCI 发生构建的时候,就会上报单元测试覆盖率到 coveralls 去。

参考 .circleci/config.yml 相关脚本:

...
upload-coveralls:
  <<: *defaults
  steps:
    - attach_workspace:
        at: ~/repo
    - run:
        name: Test Coverage
        command: npm run cov
    - run:
        name: Report for coveralls
        command: |
          npm install coveralls --save-dev
          ./node_modules/.bin/nyc report --reporter=text-lcov | ./node_modules/.bin/coveralls
    - store_artifacts:
        path: coverage
...

3. Doc Site to Github Pages

本地可以通过命令来构建和发布文档站点到 Github Pages

npm run doc
npm run doc:publish

如果这个动作交给 CircleCI 来完成,则需要为 Repo 添加一个 Read/Write 权限的 User Key

那么,每次 CircleCI 发生构建的时候,就会构建文档,并发布到 Github Pages 中去。

例如本项目,就可以通过以下地址访问:

https://jerryc8080.github.io/module-seed

参考 .circleci/config.yml 相关脚本:

...
 deploy-docs:
    <<: *defaults
    steps:
      - attach_workspace:
          at: ~/repo
      - run:
          name: Avoid hosts unknown for github
          command: mkdir ~/.ssh/ && echo -e "Host github.com\n\tStrictHostKeyChecking no\n" > ~/.ssh/config
      - run:
          name: Set github email and user
          command: |
            git config --global user.email "huangjerryc@gmail.com"
            git config --global user.name "CircleCI-Robot"
      - run:
          name: Show coverage
          command: ls coverage
      - run:
          name: Show docs
          command: ls docs
      - run:
          name: Copy to docs folder
          command: |
            mkdir docs/coverage
            cp -rf coverage/* docs/coverage
      - run:
          name: Show docs
          command: ls docs
      - run:
          name: Publish to gh-pages
          command: npm run doc:publish
...

4. Coverage site

在 CircleCI 的 deploy-docs 任务中,会构建 Coverage Site ,然后一起发布到 Github Pages 的 /coverage 目录中。

例如本项目,就可以通过以下地址访问:

https://jerryc8080.github.io/module-seed/coverage/index.html

5. NPM Deploy

自动化脚本会以 patch 的形式升级版本号,例如:v0.0.1v0.0.2
然后发布到 npmjs.com 去。

如果需要启用这一功能,需要为 CircleCI Repo 添加 npm token

首先,获取 npm token


然后,为 CircleCI Repo 添加环境变量:npm_TOKEN

那么,每次 CircleCI 发生构建的时候,就会构建和发布 NPM 包。

参考 .circleci/config.yml 相关脚本:

...
deploy:
  <<: *defaults
  steps:
    - attach_workspace:
        at: ~/repo
    - run:
        name: Set github email and user
        command: |
          # You should change email to yours.
          git config --global user.email "huangjerryc@gmail.com"
          git config --global user.name "CircleCI-Robot"
    - run:
        name: Authenticate with registry
        command: echo "//registry.npmjs.org/:_authToken=$npm_TOKEN" > ~/repo/.npmrc
    - run:
        name: Update version as patch
        command: npm run version
    - run:
        name: Publish package
        command: npm publish
...
查看原文

赞 0 收藏 0 评论 0

JerryC 发布了文章 · 1月28日

函数保险丝:避免函数过热调用

前言

在日常开发中,我们会遇到很多这样的场景:

  1. 在抢购活动中,用户往往会频繁刷新接口,要给接口加上防护,频繁调用停止响应。
  2. 在弱网环境中,往往会实现失败重试功能,如果失败次数多了,频繁的重试需要制止。
  3. 在股票市场中,当价格波动的幅度在交易时间中达到某一个限定的熔断点时,对其暂停交易一段时间的机制。
  4. ......

这类问题,本质是:「过热的调用」

在物理电路中,对于「过热的调用」有一种大家生活中都常见的电子元件:保险丝

保险丝会在电流异常升高到一定的高度和热度的时候,自身熔断切断电流,保护电路安全运行。

我们可以模仿这样的思路,去解决编程中的「过热的调用」问题:

  1. 设定一个阈值,如果函数在短时间内调用次数达到这个阈值,就熔断一段时间。
  2. 在函数有一段时间没有被调用了,让函数的热度降下来。

函数保险丝的功能实现

基于以上的思路,我实现了一个 npm 库:Method Fuse

使用方式如下:

Step1:安装

npm install @jerryc/method-fuse

Step2:快速使用

import { MethodFuse } from '@jerryc/method-fuse';

// 一个请求远程资源的异步函数
const getAssets = async () => API.requestAssets();

// 创建 MethodFuse 实例
const fuse = new MethodFuse({
  // 命名,用于日志输出
  name: 'TestFuse',

  // 最大负荷,默认:3
  maxLoad: 3,

  // 每次熔断时间。每次熔断之后,间隔 N 毫秒之后重铸,默认:5000ms
  breakingTime: 5000,

  // 自动冷却时间。在最后一次调用间隔 N 毫秒之后自动重铸,默认:1000ms
  coolDownTime: 1000,
});

// 代理原函数
const getAssetsProxy = fuse.proxy(getAssets);

// 高频并发调用 getAssetsProxy。
getAssetsProxy();
getAssetsProxy();
getAssetsProxy();
getAssetsProxy(); // 此次调用会熔断
setTimeout(() => getAssetsProxy(), 5000); // 等待熔断重铸后,方可重新调用。

// 以上会打印日志:
// [method-fuse:info] TestFuse-通过保险丝(1/3)
// [method-fuse:info] TestFuse-通过保险丝(2/3)
// [method-fuse:info] TestFuse-通过保险丝(3/3)
// [method-fuse:error] TestFuse-保险丝熔断,5000ms 之后重铸
// [method-fuse:info] TestFuse-保险丝重置
// [method-fuse:info] TestFuse-通过保险丝(1/3)

Step3:使用装饰器

如果你的项目中支持 TS 或者 ES Decorator,那么 MethodFuse 提供了快捷使用的装饰器。

import { decorator as methodFuse } from '@jerryc/method-fuse';

@methodFuse({ name: 'TestFuse' })
async function getAsset() {
  return API.requestAssets();
};

Step4:修改日志级别

MethodFuse 内置了一个迷你 logger(power by @jerryc/mini-logger),方便内部日志打印,外部可以获得 Logger 的实例,进行 log level 的控制。

import { LoggerLevel } from '@jerryc/mini-logger';
import { logger, MethodFuse } from '@jerryc/method-fuse';

// 创建 MethodFuse 实例
const MethodFuse = new MethodFuse({ name: 'TestFuse' });

// 下调 Log level
logger.level = LoggerLevel.ERROR;
查看原文

赞 0 收藏 0 评论 0

JerryC 赞了文章 · 2020-12-25

Vue 3 Virtual Dom Diff源码阅读

前言

学完了React、Vue2的diff算法,又到了学Vue3的时候了,Vue3出来了一段时间,不了解一下说不过去。

在阅读之前,需要了解几个概念:
1、vNode是什么?
2、为什么要进行Diff

VNode - 源码版

这篇文章主要分为两部分:
一、diff算法大体的流程和实现思路
二、深入源码,看看具体的实现

核心diff思路

diff (2).png
我们都知道,通常我们对比时只有当是相同的父元素时,只有当父元素是相同的节点时,才会往下遍历。那我们假设他们的父节点是相同的,直接开始进行子节点们的比较。为了区分不同的场景下的思路,每一个部分都会举的不同的例子。

预处理优化

我们先来看一下下面这两组简单的节点对比,在Vue3中首先会进行头尾的遍历,进行预处理优化。

1、从头开始遍历

首先会遍历开始节点,判断新老的第一个节点是否一致,一致的话,执行patch方法更新差异,然后往下继续比较,否则break跳出。可以看到下图中,A vs A 是一样的,然后去比较B,B也是一样的,然后去比较C vs D,发现不一样了,于是跳出当前循环。
image.png

2、尾部开始

接着我们开始从后往前遍历,也是找相同的元素,G vs G,一致,那么执行patch后往前对比,F vs F一致,继续往前diff,直到E和C不一致,跳出循环。
image.png

3、一方已经处理完毕

目前新节点还剩下一个新增节点,那么我们就会去判断是否老节点遍历完毕,然后新增它。下图的C节点则是要新增。
如果是老节点还剩下一个多余节点,则会去判断新节点是否遍历完毕,然后卸载它。下图的I节点则是要卸载。
image.png


到了这一步,肯定有人想问,为什么要这么做呢?

但其实大家直觉都知道是为什么,平时我们在修改列表的时候,有一些比较常见场景,比如说列表中间节点的增加、删除、修改等,如果使用了这样的方式查找,可以减少diff的时间,甚至可以不用diff来达到我们想要的结果,并且还可以减少后续diff的复杂度。这个预处理优化策略,是Neil Fraser提出的。


这里应该都有了一些了解,那么接下来还没有走到的场景是新老节点都还剩余有多个子节点存在的情况。那我们再想一想,如果是我们去做这样的一个需求,我们会怎么做呢?

我第一时间想到了Vue2的方式,新老节点去遍历查找然后进行移动。但是如果这样的话,好像跟Vue2相比好像不一定更好。在Vue2遍历时,我们使用的是交叉遍历的方式。那这种方式解决的主要是什么问题呢?举个简单的例子:
image.png
这个例子如果在我们刚刚的流程里,是不会做任何操作的,但是Vue2去遍历的时候会进行交叉首尾遍历,然后一个个的匹配到,并且在第一次匹配到G节点的时候,就会把G节点移动到A节点前面,后续匹配ABF节点的时候,只需要去patch,但是不需要move了,因为将G节点移动到A前面后,真实DOM节点的顺序就已经与新节点一致了。
按照前面我去遍历的思路,需要移动四次,如图:
image.png

那么问题来了,接下来该怎么做能够在之前优化的基础上继续优化呢?

好像我们找到持续递增的那列节点,就知道哪些节点是可以稳定不变的。
这里引入一个概念,叫最长递增子序列。
官方解释:在一个给定的数组中,找到一组递增的数值,并且长度尽可能的大。
有点比较难理解,那来看具体例子:

const arr = [10, 9, 2, 5, 3, 7, 101, 18]
=> [2, 3, 7, 18, 30]
这一列数组就是arr的最长递增子序列

想更深入了解它可以看一下这道题:最长增长子序列

所以如果我们能够找到,老节点在新节点序列中顺序不变的节点们,就知道,哪一些节点不需要移动,然后只需要把不在这里的节点插入进来就可以了。在此之前,我们先把所有节点都找到,在找到对应的序列。

4、找到新节点对应的老节点坐标

最后一个新例子,要diff这两组节点,上面是老节点,下面是新节点~通过上面的铺垫,我们得知了,要找到这样一个数组[2, 3, 新增, 0],不过因为数组的初始值是0,代表的是新增的意思,所以我们将这个坐标+1,新增的变为0,也就是[3, 4, 0, 1],我们可以看成第1位,第2位,第3位的意思。
image.png

找到这个数组就很简单了,我们先遍历老节点,找到对应的新节点,然后加入到新节点对应的坐标上。我们开始遍历了,在遍历过程中,会执行patch和卸载操作,如下图表格:

当前老坐标下标当前找到的新节点坐标新节点坐标下所对应的旧节点数组(初始值为0,代表新增,加进来坐标+1)
03[0, 0, 0, 1]
1卸载
20[3, 0, 0, 1]
31[3, 4, 0, 1]

遍历完数组后,最后得到的数组为[3, 4, 0, 1],然后我们会找到它的最长增长子序列为3, 4,它所对应的是第一个节点D和第二个节点E,所以这两个节点是不需要动的。
最后我们再遍历新节点,如果我们当前的节点与在最长增长子序列中,则不移动,为0则直接新增,剩下的则移动到当前位置。

到这里大致的流程就已经结束了,我们在跟着源码进行一次深入的了解吧~

源码

源码文件路径:packages/runtime-core/src/renderer.ts
源码仓库地址:vue-next

patchChildren

我们从patchChildren方法开始,进行子节点之间的比较。

const patchChildren: PatchChildrenFn = () => {
    // 获得当前新旧节点下的子节点们
    const c1 = n1 && n1.children
    const prevShapeFlag = n1 ? n1.shapeFlag : 0
    const c2 = n2.children

    const { patchFlag, shapeFlag } = n2
    // fragment有两种类型的静态标记:子节点有key、子节点无key
    if (patchFlag > 0) {
      if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
        // 子节点全部或者部分有key
        patchKeyedChildren()
        return
      } else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
        // 子节点没有key
        patchUnkeyedChildren()
        return
      }
    }

    // 子节点有三种可能:文本节点、数组(至少一个子节点)、没有子节点
    if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
      // 匹配到当前是文本节点:卸载之前的节点,为其设置文本节点
      unmountChildren()
      hostSetElementText()
    } else {
      // old子节点是数组
      if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
          // 现在(new)也是数组(至少一个子节点),直接full diff(调用patchKeyedChildren())
        } else {
          // 否则当前没有子节点,直接卸载当前所有的子节点
          unmountChildren()
        }
      } else {
        // old的子节点是文本或者没有
        if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
          // 清空文本
          hostSetElementText(container, '')
        }
        // 现在(new)的节点是多个子节点,直接新增
        if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
          // 新建子节点
          mountChildren()
        }
      }
    }
  }

我们可以直接用文本描述一下这段代码:
1、获得当前新旧节点下的子节点们(c1、c2)
2、使用patchFlag进行按位与判断fragment的子节点是否有key(patchFlag是什么稍后下面说)
3、不管有没有key,只要匹配成功一定是数组,有key/部分有key则调用patchKeyedChildren方法进行diff计算,无key则调用patchUnkeyedChildren方法
4、不是fragment节点,那么子节点有三种可能:文本节点、数组(至少一个子节点)、没有子节点
5、如果new的子节点是文本节点:old有子节点的话则直接进行卸载,并为其设置文本节点
6、否则new的子节点是数组 or 无节点,在这个基础上:
7、如果old的子节点为数组,那么new的子节点也是数组的话,调用patchKeyedChildren方法,直接full diff,否则new没有子节点,直接进行卸载
8、最后old的子节点为文本节点 or 没有节点(此时新节点可能为数组,也可能没有节点),所以当old的子节点为文本节点,那么则清空文本,new节点如果是数组的话,直接新增
9、此时所有的情况已经处理完毕了,不过真正的diff还没开始,那我们来看一下没有key的情况下,是否进行diff的

patchUnkeyedChildren

没有key的处理比较简单,直接上删减版源码

const patchUnkeyedChildren = () => {
    c1 = c1 || EMPTY_ARR
    c2 = c2 || EMPTY_ARR
    const oldLength = c1.length
    const newLength = c2.length
    // 拿到新旧节点的最小长度
    const commonLength = Math.min(oldLength, newLength)
    let i
    // 遍历新旧节点,进行patch
    for (i = 0; i < commonLength; i++) {
      // 如果新节点已经挂载过了(已经过了各种处理),则直接clone一份,否则创建一个新的vnode节点
      const nextChild = (c2[i] = optimized
        ? cloneIfMounted(c2[i] as VNode)
        : normalizeVNode(c2[i]))
      patch()
    }
    // 如果旧节点的数量大于新节点数量
    if (oldLength > newLength) {
      // 直接卸载多余的节点
      unmountChildren( )
    } else {
      // old length < new length => 直接进行创建
      mountChildren()
    }
  }

我们继续文本描述一下逻辑:
1、首先会拿到新旧节点的最短公共长度
2、然后遍历公共部分,直接进行patch
3、如果旧节点的数量大于新节点数量,直接卸载多余的节点,否则新建节点

patchKeyedChildren

到了Diff算法比较核心的部分,我们先看一个大概预览,了解一下流程~再把patchKeyedChildren源码内部拆分一下,逐步来看。

  const patchKeyedChildren = () => {
    let i = 0
    const l2 = c2.length
    let e1 = c1.length - 1 // prev ending index
    let e2 = l2 - 1 // next ending index

    // 1. 进行头部遍历,遇到相同节点则继续,不同节点则跳出循环
    while (i <= e1 && i <= e2) {}

    // 2. 进行尾部遍历,遇到相同节点则继续,不同节点则跳出循环
    while (i <= e1 && i <= e2) {}

    // 3. 如果旧节点已遍历完毕,并且新节点还有剩余,则遍历剩下的进行新增
    if (i > e1) {
      if (i <= e2) {}
    }

    // 4. 如果新节点已遍历完毕,并且旧节点还有剩余,则直接卸载
    else if (i > e2) {
      while (i <= e1) {}
    }

    // 5. 新旧节点都存在未遍历完的情况
    else {
      // 5.1 创建一个map,为剩余的新节点存储键值对,映射关系:key => index
      // 5.2 遍历剩下的旧节点,新旧数据对比,移除不使用的旧节点
      // 5.3 拿到最长递增子序列进行move or 新增挂载
    }
  }

1、第一步是进行头部遍历,遇到相同节点则继续,下标 + 1,不同节点则跳出循环

    // 1. sync from start
    // (a b) c
    // (a b) d e
    while (i <= e1 && i <= e2) {
      const n1 = c1[i]
      // 如果新节点已经挂载过了(已经经历了各种处理),则直接clone一份,否则创建一个新的vnode节点
      const n2 = (c2[i] = optimized
        ? cloneIfMounted(c2[i] as VNode)
        : normalizeVNode(c2[i]))
      // 相同节点,则继续执行patch方法  
      if (isSameVNodeType(n1, n2)) {
        patch()
      } else {
        break
      }
      i++
    }

1.png

此时i = 2, e1 = 6, e2 = 7, 旧节点剩下C、D、E、F、G,新节点剩下D、E、I、C、F、G

这里判断是否为相同节点的方法isSameVNodeType,是通过类型和key来进行判断,在Vue2中是通过key和sel(属性选择器)来判断是否是相同元素。这里的类型指的是ShapeFlag,也是一个标志位,是对元素的类型进行不同的分类,比如:元素、组件、fragment、插槽等等

export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
  return n1.type === n2.type && n1.key === n2.key
}

2、第二步是进行尾部遍历,遇到相同节点则继续,length - 1,不同节点则跳出循环

    // 2. sync from end
    // a (b c)
    // d e (b c)
    // 进行尾部遍历,遇到相同节点则继续,不同节点则跳出循环
    while (i <= e1 && i <= e2) {
      const n1 = c1[e1]
      const n2 = (c2[e2] = optimized
          ? cloneIfMounted(c2[e2] as VNode)
          : normalizeVNode(c2[e2]))
      if (isSameVNodeType(n1, n2)) {
          patch()
      } else {
          break
      }
      e1--
      e2--
    }

2.png

此时i = 2, e1 = 4, e2 = 5, 旧节点剩下C、D、E,新节点剩下D、E、I、C

3、如果旧节点已遍历完毕,并且新节点还有剩余,则遍历剩下的进行新增

    // 3.common sequence + mount
    // (a b)
    // (a b) c
    // i = 2, e1 = 1, e2 = 2
    // (a b)
    // c (a b)
    // i = 0, e1 = -1, e2 = 0
    if (i > e1) {
      if (i <= e2) {
        const nextPos = e2 + 1
        const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
        while (i <= e2) {
          patch(null, c2[i]) // 节点新增(伪代码)
          i++
        }
      }
    }

因为我们上面的图例(i < e1)走不到这段逻辑,所以我们可以直接看一下代码注释(注释真的写得非常详细了,patchKeyedChildren里面的原注释我都保留了)。如果旧节点遍历完毕,开头或者尾部还剩下了新节点,则进行节点新增(通过传参,patch内部会处理)。

4、如果新节点已经遍历完毕,则说明多余的节点需要卸载

    // 4.common sequence + unmount
    // (a b) c
    // (a b)
    // i = 2, e1 = 2, e2 = 1
    // a (b c)
    // (b c)
    // i = 0, e1 = 0, e2 = -1
    else if (i > e2) {
      while (i <= e1) {
        unmount(c1[i], parentComponent, parentSuspense, true)
        i++
      }
    }

因为我们上面的图例(i < e2)依然走不到这段逻辑,所以我们可以继续看一下原注释。i > e2意味着新节点遍历完毕,如果新节点遍历完毕,开头或者尾部还剩下了旧节点,则进行节点卸载unmount

5、新旧节点都没有遍历完成的情况

    // 5. unknown sequence
    // [i ... e1 + 1]: a b [c d e] f g
    // [i ... e2 + 1]: a b [e d c h] f g
    // i = 2, e1 = 4, e2 = 5
    else {
      const s1 = i // prev starting index
      const s2 = i // next starting index
      
      ...
    }

按照上面图的例子来看,s1 = 2, s2 = 2,旧节点剩下C、D、E,新节点剩下D、E、I、C需要继续进行diff

5.1、生成map对象,通过键值对的方式存储新节点的key => index

      // 5.1 build key:index map for newChildren
      // 创建一个空的map对象
      const keyToNewIndexMap = new Map()
      // 遍历剩下没有patch的新节点,也就是D、E、I、H
      for (i = s2; i <= e2; i++) {
        const nextChild = (c2[i] = optimized
          ? cloneIfMounted(c2[i] as VNode)
          : normalizeVNode(c2[i]))
        // 如果剩余的新节点有key的话,则将其存储起来,key对应index
        if (nextChild.key != null) {
          keyToNewIndexMap.set(nextChild.key, i)
        }
      }

执行完上面的方法,得到keyToNewIndexMap = {D => 2, E => 3, I => 4, C => 5},keyToNewIndexMap主要用来干嘛呢~请继续往下看

5.2、遍历剩下的旧节点,新旧数据对比,移除不使用的旧节点

      // 5.2 loop through old children left to be patched and try to patch
      // matching nodes & remove nodes that are no longer present
      
      let j
      // 记录即将被patch过的新节点数量
      let patched = 0
      // 拿到剩下要遍历的新节点的长度,按照上面的图示toBePatched = 4
      const toBePatched = e2 - s2 + 1
      // 是否发生过移动
      let moved = false
      // 用于跟踪是否有任何节点移动
      let maxNewIndexSoFar = 0
      
      // works as Map<newIndex, oldIndex>
      // 注意:旧节点 oldIndex偏移量 + 1
      // 并且oldIndex = 0是一个特殊值,代表新节点没有对应的旧节点
      // newIndexToOldIndexMap主要作用于最长增长子序列
      // newIndexToOldIndexMap从变量名可以看出,它代表的是新旧节点的对应关系
      const newIndexToOldIndexMap = new Array(toBePatched)
      
      for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
      // 此时newIndexToOldIndexMap = [0, 0, 0, 0]
      // 遍历剩余旧节点的长度
      for (i = s1; i <= e1; i++) {
        const prevChild = c1[i]
        if (patched >= toBePatched) {
          // patched大于剩余新节点的长度时,代表当前所有新节点已经patch了,因此剩下的节点只能卸载
          unmount(prevChild, parentComponent, parentSuspense, true)
          continue
        }
        let newIndex
        if (prevChild.key != null) {
          // 旧节点的key存在的话,则通过旧节点的key找到对应的新节点的index位置下标
          newIndex = keyToNewIndexMap.get(prevChild.key)
        } else {
          // 旧节点没有key的话,则遍历所有的新节点
          for (j = s2; j <= e2; j++) {
            // newIndexToOldIndexMap[j - s2]如果等于0的话
            // 代表当前新节点还没有被patch,因为在下面的运算中
            // 如果找到新节点对应的旧节点位置,newIndexToOldIndexMap[j - s2]则会等于旧节点的下标 + 1
            if (
              newIndexToOldIndexMap[j - s2] === 0 &&
              isSameVNodeType(prevChild, c2[j] as VNode)
            ) {
              // 当前新节点还没有被找到,并新旧节点相同,则将新节点的位置赋予newIndex
              newIndex = j
              break
            }
          }
        }
        
        if (newIndex === undefined) {
          // 当前旧节点没有找到对应的新节点,则进行卸载
          unmount(prevChild, parentComponent, parentSuspense, true)
        } else {
          // 找到了对应的新节点,则将旧节点的位置存储在对应的新节点下标
          newIndexToOldIndexMap[newIndex - s2] = i + 1
          // maxNewIndexSoFar如果不是逐级递增,则代表有新节点的位置前移了,那么需要进行移动
          if (newIndex >= maxNewIndexSoFar) {
            maxNewIndexSoFar = newIndex
          } else {
            moved = true
          }
          // 更新节点差异
          patch()
          // 找到一个对应的新节点,+1
          patched++
        }
      }

这段代码比较长,但是总的来说做了下面几件事:
1、拿到新节点对应的旧节点下标newIndexToOldIndexMap(下标+1,因为0代表的是新节点没有对应的旧节点,直接创建新节点),在我们的图例中newIndexToOldIndexMap = [4, 5, 0, 3]

2、存在在遍历的过程中,如果老节点找到对应的新节点,则进行打补丁,更新节点差异,找不到则删除该老节点

3️、通过新节点下标的顺序是否递增来判断,是否有节点发生过移动

5.3、对剩下没有找到的新节点进行挂载,对需要移动的节点进行移动

      // 5.3 move and mount
      // 仅在有节点需要移动的时候才生成最长递增子序列
      const increasingNewIndexSequence = moved
        ? getSequence(newIndexToOldIndexMap)
        : EMPTY_ARR
      j = increasingNewIndexSequence.length - 1
      // 此时图示中的increasingNewIndexSequence = [4, 5]
      // 从后面开始遍历,将最后一个patch的节点用作锚点
      for (i = toBePatched - 1; i >= 0; i--) {
        const nextIndex = s2 + i
        const nextChild = c2[nextIndex] as VNode
        const anchor =
          nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
          
        // 代表新增
        if (newIndexToOldIndexMap[i] === 0) {
          // mount new
          patch( )
        } else if (moved) {
          // 移动的条件:当前最长子序列的length小于0(没有稳定的子序列),或者当前的节点不在稳定的序列中
          if (j < 0 || i !== increasingNewIndexSequence[j]) {
            move(nextChild, container, anchor, MoveType.REORDER)
          } else {
            j--
          }
        }
      }

最后这段源码用到了一个优化方法,最长上升子序列,这段大致的流程就是:
1、通过moved来判断当前是否有节点进行了移动,如果有的话则通过getSequence(newIndexToOldIndexMap)拿到最长上升子序列,我们的图示中拿到的是increasingNewIndexSequence = [4, 5]

2、遍历剩余新节点的长度,从后面开始遍历,判断newIndexToOldIndexMap[i] === 0,当前的新节点是否有对应的老节点,如果等于0,就是没有,直接新增。

3、否则通过moved判断是否有移动,有移动的话,如果当前最长子序列的length小于0,或者当前的节点不在稳定的序列中,则意味着现在没有稳定的子序列,每个节点需要进行移动,或者,最后一个新节点,不在末尾的子序列中,子序列的末尾另有他人,那当前也需要进行移动。若是不符合移动的条件,则说明当前新节点在最长上升子序列中,不需要进行移动,只用等待别的节点去移动。

到这里,diff算法的核心流程就了解得差不多了~有机会再把最长子序列求解补上。

参考资料:
源码:https://github.com/vuejs/vue-...
diff优化策略:https://neil.fraser.name/writ...
inforno:https://github.com/infernojs/inferno
https://blog.csdn.net/u014125...
https://zhuanlan.zhihu.com/p/...
https://hustyichi.github.io/2...
https://www.cnblogs.com/Windr...
查看原文

赞 10 收藏 3 评论 1

JerryC 赞了回答 · 2020-12-09

解决javascript 连等赋值问题

同意3楼和4楼同学说的。连等是先确定所有变量的指针,再让指针指向那个赋值({n:3})。

对于 a.x = a = {n:2},楼主原先的思路应该是:

  1. 先把 {n:2} 赋值给 a
  2. 然后再创建 a.x,将 {n:2} 再赋值给 a.x

这样似乎确实说不通 a.x 的值是 undefined,因为 a.x 确实是被赋值了的啊。
可是事实上,a.x 的值却是 undefined。

再来看一下这个: a = a.x = {n:2}的话,按楼主原先的思路应该是:

  1. 先把 {n:2} 赋值给 a.x,那么也就相当于 b.x = {n:2}
  2. 再把 a 重新指向 {n:2}。那么这是后 a.x 的值确实是 undefined,a 对象 {n:2} 中就没有 x 属性嘛。

按楼主的思路,上述两种方式的结果应该是不同的。但事实却是a = a.x = {n:2}a.x = a = {n:2}的结果是一致的。所以楼主的那种赋值的思路是不对的。

事实上,解析器在接受到 a = a.x = {n:2} 这样的语句后,会这样做:

  1. 找到 a 和 a.x 的指针。如果已有指针,那么不改变它。如果没有指针,即那个变量还没被申明,那么就创建它,指向 null。
    a 是有指针的,指向 {n:1};a.x 是没有指针的,所以创建它,指向 null。
  2. 然后把上面找到的指针,都指向最右侧赋的那个值,即 {n:2}

所以执行以后,就有了如下的变量关系图。楼主可以慢慢体会下,想通了就很简单的。

变量关系图

关注 76 回答 17

JerryC 发布了文章 · 2020-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未失效,但是解密手机号失败
查看原文

赞 75 收藏 50 评论 3

JerryC 关注了用户 · 2020-10-10

百度小程序技术 @smartprogram

关注 79

JerryC 赞了文章 · 2020-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 评论 1

JerryC 赞了文章 · 2020-07-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协议的需要
参考资料:
《网络是怎么连接的》
查看原文

赞 8 收藏 1 评论 2

JerryC 赞了文章 · 2020-07-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》
查看原文

赞 13 收藏 3 评论 4

JerryC 赞了文章 · 2020-06-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 (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

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

查看原文

赞 4 收藏 2 评论 0

JerryC 赞了文章 · 2020-05-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 关注了专栏 · 2020-05-27

前端巅峰

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

关注 21020

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

Henry @xujing_1986

关注 3

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 的设计是需要投入相当精力的,希望本文可以为你学习这一新特性提供一些帮助。

查看原文

赞 31 收藏 25 评论 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,源码层面其实就是干了一件递归改循环的事情,日后有机会的话,我再结合源码作进一步的介绍。

查看原文

赞 166 收藏 83 评论 11

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 只是一个工具,当你有一个目标,它会给你一种落实目标的方法论。而如果一开始目标没有想清楚,想明白,那就很容易在错的路上越走越远。

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

查看原文

赞 12 收藏 6 评论 1