布利丹牵驴子

布利丹牵驴子 查看完整档案

深圳编辑华中师范大学  |  计算机应用 编辑广发证券  |  前端开发工程师 编辑填写个人主网站
编辑

if you never try, you'll never know.

个人动态

布利丹牵驴子 发布了文章 · 2020-06-21

用hooks写个登录表单

最近尝试用React hooks相关api写一个登陆表单,目的就是加深一下对hooks的理解。本文不会讲解具体api的使用,只是针对要实现的功能,一步一步深入。所以阅读前要对 hooks有基本的认识。最终的样子有点像用hooks写一个简单的类似redux的状态管理模式。

细粒度的state

一个简单的登录表单,包含用户名、密码、验证码3个输入项,也代表着表单的3个数据状态,我们简单的针对username、password、capacha分别通过useState建立状态关系,就是所谓的比较细粒度的状态划分。代码也很简单:

// LoginForm.js

const LoginForm = () => {
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [captcha, setCaptcha] = useState("");

  const submit = useCallback(() => {
    loginService.login({
      username,
      password,
      captcha,
    });
  }, [username, password, captcha]);

  return (
    <div className="login-form">
      <input
        placeholder="用户名"
        value={username}
        onChange={(e) => {
          setUsername(e.target.value);
        }}
      />
      <input
        placeholder="密码"
        value={password}
        onChange={(e) => {
          setPassword(e.target.value);
        }}
      />
      <input
        placeholder="验证码"
        value={captcha}
        onChange={(e) => {
          setCaptcha(e.target.value);
        }}
      />
      <button onClick={submit}>提交</button>
    </div>
  );
};

export default LoginForm;

这种细粒度的状态,很简单也很直观,但是状态一多的话,要针对每个状态写相同的逻辑,就挺麻烦的,且太过分散。

粗粒度

我们将username、password、capacha定义为一个state就是所谓粗粒度的状态划分:

const LoginForm = () => {
  const [state, setState] = useState({
    username: "",
    password: "",
    captcha: "",
  });

  const submit = useCallback(() => {
    loginService.login(state);
  }, [state]);

  return (
    <div className="login-form">
      <input
        placeholder="用户名"
        value={state.username}
        onChange={(e) => {
          setState({
            ...state,
            username: e.target.value,
          });
        }}
      />
      ...
      <button onClick={submit}>提交</button>
    </div>
  );
};

可以看到,setXXX 方法减少了,setState的命名也更贴切,只是这个setState不会自动合并状态项,需要我们手动合并。

加入表单校验

一个完整的表单当然不能缺少验证环节,为了能够在出现错误时,input下方显示错误信息,我们先抽出一个子组件Field:

const Filed = ({ placeholder, value, onChange, error }) => {
  return (
    <div className="form-field">
      <input placeholder={placeholder} value={value} onChange={onChange} />
      {error && <span>error</span>}
    </div>
  );
};

我们使用schema-typed这个库来做一些字段定义及验证。它的使用很简单,api用起来类似React的PropType,我们定义如下字段验证:

const model = SchemaModel({
  username: StringType().isRequired("用户名不能为空"),
  password: StringType().isRequired("密码不能为空"),
  captcha: StringType()
    .isRequired("验证码不能为空")
    .rangeLength(4, 4, "验证码为4位字符"),
});

然后在state中添加errors,并在submit方法中触发model.check进行校验。

const LoginForm = () => {
  const [state, setState] = useState({
    username: "",
    password: "",
    captcha: "",
    // ++++
    errors: {
      username: {},
      password: {},
      captcha: {},
    },
  });

  const submit = useCallback(() => {
    const errors = model.check({
      username: state.username,
      password: state.password,
      captcha: state.captcha,
    });

    setState({
      ...state,
      errors: errors,
    });

    const hasErrors =
      Object.values(errors).filter((error) => error.hasError).length > 0;

    if (hasErrors) return;
    loginService.login(state);
  }, [state]);

  return (
    <div className="login-form">
      <Field
        placeholder="用户名"
        value={state.username}
        error={state.errors["username"].errorMessage}
        onChange={(e) => {
          setState({
            ...state,
            username: e.target.value,
          });
        }}
      />
        ...
      <button onClick={submit}>提交</button>
    </div>
  );
};

然后我们在不输入任何内容的时候点击提交,就会触发错误提示:
Jietu20200530-150144.jpg

useReducer改写

到这一步,感觉我们的表单差不多了,功能好像完成了。但是这样就没问题了吗,我们在Field组件打印 console.log(placeholder, "rendering"),当我们在输入用户名时,发现所的Field组件都重新渲染了。这是可以试着优化的。
Jietu20200530-152230.jpg
那要如何做呢?首先要让Field组件在props不变时能避免重新渲染,我们使用React.memo来包裹Filed组件。

React.memo 为高阶组件。它与 React.PureComponent 非常相似,但只适用于函数组件。如果你的函数组件在给定相同 props 的情况下渲染相同的结果,那么你可以通过将其包装在 React.memo 中调用,以此通过记忆组件渲染结果的方式来提高组件的性能表现

export default React.memo(Filed);

但是仅仅这样的话,Field组件还是全部重新渲染了。这是因为我们的onChange函数每次都会返回新的函数对象,导致memo失效了。
我们可以把Filed的onChange函数用useCallback包裹起来,这样就不用每次组件渲染都生产新的函数对象了。

const changeUserName = useCallback((e) => {
  const value = e.target.value;
  setState((prevState) => { // 注意因为我们设置useCallback的依赖为空,所以这里要使用函数的形式来获取最新的state(preState)
    return {
      ...prevState,
      username: value,
    };
  });
}, []);

还有没有其他的方案呢,我们注意到了useReducer,

useReducer 是另一种可选方案,它更适合用于管理包含多个子值的 state 对象。它是useState 的替代方案。它接收一个形如 (state, action) => newState 的 reducer,并返回当前的 state 以及与其配套的 dispatch 方法。并且,使用 useReducer 还能给那些会触发深更新的组件做性能优化,因为你可以向子组件传递 dispatch 而不是回调函数

useReducer的一个重要特征是,其返回的dispatch 函数的标识是稳定的,并且不会在组件重新渲染时改变。那么我们就可以将dispatch放心传递给子组件而不用担心会导致子组件重新渲染。
我们首先定义好reducer函数,用来操作state:

const initialState = {
  username: "",
  ...
  errors: ...,
};

// dispatch({type: 'set', payload: {key: 'username', value: 123}})
function reducer(state, action) {
  switch (action.type) {
    case "set":
      return {
        ...state,
        [action.payload.key]: action.payload.value,
      };
    default:
      return state;
  }
}

相应的在LoginForm中调用userReducer,传入我们的reducer函数和initialState

const LoginForm = () => {
  const [state, dispatch] = useReducer(reducer, initialState);

  const submit = ...

  return (
    <div className="login-form">
      <Field
        name="username"
        placeholder="用户名"
        value={state.username}
        error={state.errors["username"].errorMessage}
        dispatch={dispatch}
      />
      ...
      <button onClick={submit}>提交</button>
    </div>
  );
};

在Field子组件中新增name属性标识更新的key,并传入dispatch方法

const Filed = ({ placeholder, value, dispatch, error, name }) => {
  console.log(name, "rendering");
  return (
    <div className="form-field">
      <input
        placeholder={placeholder}
        value={value}
        onChange={(e) =>
          dispatch({
            type: "set",
            payload: { key: name, value: e.target.value },
          })
        }
      />
      {error && <span>{error}</span>}
    </div>
  );
};

export default React.memo(Filed);

这样我们通过传入dispatch,让子组件内部去处理change事件,避免传入onChange函数。同时将表单的状态管理逻辑都迁移到了reducer中。

全局store

当我们的组件层级比较深的时候,想要使用dispatch方法时,需要通过props层层传递,这显然是不方便的。这时我们可以使用React提供的Context api来跨组件共享的状态和方法。

Context 提供了一个无需为每层组件手动添加 props,就能在组件树间进行数据传递的方法

函数式组件可以利用createContextuseContext来实现。

这里我们不再讲如何用这两个api,大家看看文档基本就可以写出来了。我们使用unstated-next来实现,它本质上是对上述api的封装,使用起来更方便。

我们首先新建一个store.js文件,放置我们的reducer函数,并新建一个useStore hook,返回我们关注的state和dispatch,然后调用createContainer并将返回值Store暴露给外部文件使用。

// store.js
import { createContainer } from "unstated-next";
import { useReducer } from "react";

const initialState = {
  ...
};

function reducer(state, action) {
  switch (action.type) {
    case "set":
        ...
    default:
      return state;
  }
}

function useStore() {
  const [state, dispatch] = useReducer(reducer, initialState);

  return { state, dispatch };
}

export const Store = createContainer(useStore);

接着我们将LoginForm包裹一层Provider

// LoginForm.js
import { Store } from "./store";

const LoginFormContainer = () => {
  return (
    <Store.Provider>
      <LoginForm />
    </Store.Provider>
  );
};

这样在子组件中就可以通过useContainer随意的访问到state和dispatch了

// Field.js
import React from "react";
import { Store } from "./store";

const Filed = ({ placeholder, name }) => {
  const { state, dispatch } = Store.useContainer();

  return (
    ...
  );
};

export default React.memo(Filed);

可以看到不用考虑组件层级就能轻易访问到state和dispatch。但是这样一来每次调用dispatch之后state都会变化,导致Context变化,那么子组件也会重新render了,即使我只更新username, 并且使用了memo包裹组件。

当组件上层最近的 <MyContext.Provider> 更新时,该 Hook 会触发重渲染,并使用最新传递给 MyContext provider 的 context value 值。即使祖先使用 React.memo 或 shouldComponentUpdate,也会在组件本身使用 useContext 时重新渲染

Jietu20200531-104643.jpg
那么怎么避免这种情况呢,回想一下使用redux时,我们并不是直接在组件内部使用state,而是使用connect高阶函数来注入我们需要的state和dispatch。我们也可以为Field组件创建一个FieldContainer组件来注入state和dispatch。

// Field.js
const Filed = ({ placeholder, error, name, dispatch, value }) => {
  // 我们的Filed组件,仍然是从props中获取需要的方法和state
}

const FiledInner = React.memo(Filed); // 保证props不变,组件就不重新渲染

const FiledContainer = (props) => {
  const { state, dispatch } = Store.useContainer();
  const value = state[props.name];
  const error = state.errors[props.name].errorMessage;
  return (
    <FiledInner {...props} value={value} dispatch={dispatch} error={error} />
  );
};

export default FiledContainer;

这样一来在value值不变的情况下,Field组件就不会重新渲染了,当然这里我们也可以抽象出一个类似connect高阶组件来做这个事情:

// Field.js
const connect = (mapStateProps) => {
  return (comp) => {
    const Inner = React.memo(comp);

    return (props) => {
      const { state, dispatch } = Store.useContainer();
      return (
        <Inner
          {...props}
          {...mapStateProps(state, props)}
          dispatch={dispatch}
        />
      );
    };
  };
};

export default connect((state, props) => {
  return {
    value: state[props.name],
    error: state.errors[props.name].errorMessage,
  };
})(Filed);

dispatch一个函数

使用redux时,我习惯将一些逻辑写到函数中,如dispatch(login()),
也就是使dispatch支持异步action。这个功能也很容易实现,只需要装饰一下useReducer返回的dispatch方法即可。

// store.js
function useStore() {
  const [state, _dispatch] = useReducer(reducer, initialState);

  const dispatch = useCallback(
    (action) => {
      if (typeof action === "function") {
        return action(state, _dispatch);
      } else {
        return _dispatch(action);
      }
    },
    [state]
  );

  return { state, dispatch };
}

如上我们在调用_dispatch方法之前,判断一下传来的action,如果action是函数的话,就调用之并将state、_dispatch作为参数传入,最终我们返回修饰后的dispatch方法。

不知道你有没有发现这里的dispatch函数是不稳定,因为它将state作为依赖,每次state变化,dispatch就会变化。这会导致以dispatch为props的组件,每次都会重新render。这不是我们想要的,但是如果不写入state依赖,那么useCallback内部就拿不到最新的state

那有没有不将state写入deps,依然能拿到最新state的方法呢,其实hook也提供了解决方案,那就是useRef

useRef返回的 ref 对象在组件的整个生命周期内保持不变,并且变更 ref的current 属性不会引发组件重新渲染

通过这个特性,我们可以声明一个ref对象,并且在useEffect中将current赋值为最新的state对象。那么在我们装饰的dispatch函数中就可以通过ref.current拿到最新的state。

// store.js
function useStore() {
  const [state, _dispatch] = useReducer(reducer, initialState);

  const refs = useRef(state);

  useEffect(() => {
    refs.current = state;
  });

  const dispatch = useCallback(
    (action) => {
      if (typeof action === "function") {
        return action(refs.current, _dispatch); //refs.current拿到最新的state
      } else {
        return _dispatch(action);
      }
    },
    [_dispatch] // _dispatch本身是稳定的,所以我们的dispatch也能保持稳定
  );

  return { state, dispatch };
}

这样我们就可以定义一个login方法作为action,如下

// store.js
export const login = () => {
  return (state, dispatch) => {
    const errors = model.check({
      username: state.username,
      password: state.password,
      captcha: state.captcha,
    });

    const hasErrors =
      Object.values(errors).filter((error) => error.hasError).length > 0;

    dispatch({ type: "set", payload: { key: "errors", value: errors } });

    if (hasErrors) return;
    loginService.login(state);
  };
};

在LoginForm中,我们提交表单时就可以直接调用dispatch(login())了。

const LoginForm = () => {
  const { state, dispatch } = Store.useContainer();
  
  .....
return (
  <div className="login-form">
    <Field
      name="username"
      placeholder="用户名"
    />
      ....
    <button onClick={() => dispatch(login())}>提交</button>
  </div>
);
}

一个支持异步action的dispatch就完成了。

结语

看到这里你会发现,我们使用hooks的能力,实现了一个简单的类似redux的状态管理模式。目前hooks状态管理还没有出现一个被普遍接受的模式,还有折腾的空间。最近Facebook新出的recoil,有空可以研究研究。
上面很多时候,我们为了避免子组件重新渲染,多写了很多逻辑,包括使用useCallback、memeo、useRef。这些函数本身是会消耗一定的内存和计算资源的。事实上render对现代浏览器来说成本很低,所以有时候我们没必要做提前做这些优化,当然本文只是以学习探讨为目的才这么做的。
大家有空可以多看看阿里hooks这个库,能够学到很多hooks的用法,同时惊叹hooks居然可以抽象出这么多业务无关的通用逻辑。

本文完整代码

参考:

React Hooks 你真的用对了吗?
精读《React Hooks 数据流》
10个案例让你彻底理解React hooks的渲染逻辑

查看原文

赞 4 收藏 3 评论 2

布利丹牵驴子 收藏了文章 · 2020-03-30

京喜小程序的高性能打造之路

背景

京喜小程序自去年双十一上线微信购物一级入口后,时刻迎接着亿级用户量的挑战,细微的体验细节都有可能被无限放大,为此,“极致的页面性能”、“友好的产品体验” 和 “稳定的系统服务” 成为了我们开发团队的最基本执行原则。

首页作为小程序的门户,其性能表现和用户留存率息息相关。因此,我们对京喜首页进行了一次全方位的升级改造,从加载、渲染和感知体验几大维度深挖小程序的性能可塑性。

除此之外,京喜首页在微信小程序、H5、APP 三端都有落地场景,为了提高研发效率,我们使用了 Taro 框架实现多端统一,因此下文中有部分内容是和 Taro 框架息息相关的。

怎么定义高性能?

提起互联网应用性能这个词,很多人在脑海中的词法解析就是,“是否足够快?”,似乎加载速度成为衡量系统性能的唯一指标。但这其实是不够准确的,试想一下,如果一个小程序加载速度非常快,用户花费很短时间就能看到页面的主体内容,但此时搜索框却无法输入内容,功能无法被流畅使用,用户可能就不会关心页面渲染有多快了。所以,我们不应该单纯考虑速度指标而忽略用户的感知体验,而应该全方位衡量用户在使用过程中能感知到的与应用加载相关的每个节点。

谷歌为 Web 应用定义了以用户为中心的性能指标体系,每个指标都与用户体验节点息息相关:

体验指标
页面能否正常访问?首次内容绘制 (First Contentful Paint, FCP)
页面内容是否有用?首次有效绘制 (First Meaningful Paint, FMP)
页面功能是否可用?可交互时间 (Time to Interactive, TTI)

其中,“是否有用?” 这个问题是非常主观的,对于不同场景的系统可能会有完全不一样的回答,所以 FMP 是一个比较模糊的概念指标,不存在规范化的数值衡量。

小程序作为一个新的内容载体,衡量指标跟 Web 应用是非常类似的。对于大多数小程序而言,上述指标对应的含义为:

  • FCP:白屏加载结束;
  • FMP:首屏渲染完成;
  • TTI:所有内容加载完成;

综上,我们已基本确定了高性能的概念指标,接下来就是如何利用数值指标来描绘性能表现。

小程序官方性能指标

小程序官方针对小程序性能表现制订了权威的数值指标,主要围绕 渲染表现setData 数据量元素节点数网络请求延时 这几个维度来给予定义(下面只列出部分关键指标):

  • 首屏时间不超过 5 秒;
  • 渲染时间不超过 500ms;
  • 每秒调用 setData 的次数不超过 20 次;
  • setData 的数据在 JSON.stringify 后不超过 256kb;
  • 页面 WXML 节点少于 1000 个,节点树深度少于 30 层,子节点数不大于 60 个;
  • 所有网络请求都在 1 秒内返回结果;
详见 小程序性能评分规则

我们应该把这一系列的官方指标作为小程序的性能及格线,不断地打磨和提升小程序的整体体验,降低用户流失率。另外,这些指标会直接作为小程序体验评分工具的性能评分规则(体验评分工具会根据这些规则的权重和求和公式计算出体验得分)。

我们团队内部在官方性能指标的基础上,进一步浓缩优化指标系数,旨在对产品体验更高要求:

  • 首屏时间不超过 2.5 秒;
  • setData 的数据量不超过 100kb;
  • 所有网络请求都在 1 秒内返回结果;
  • 组件滑动、长列表滚动无卡顿感;

体验评分工具

小程序提供了 体验评分工具(Audits 面板) 来测量上述的指标数据,其集成在开发者工具中,在小程序运行时实时检查相关问题点,并为开发者给出优化建议。

体验评分面板

以上截图均来自小程序官方文档

体验评分工具是目前检测小程序性能问题最直接有效的途径,我们团队已经把体验评分作为页面/组件是否能达到精品门槛的重要考量手段之一。

小程序后台性能分析

我们知道,体验评分工具是在本地运行小程序代码时进行分析,但性能数据往往需要在真实环境和大数据量下才更有说服力。恰巧,小程序管理平台小程序助手 为开发者提供了大量的真实数据统计。其中,性能分析面板从 启动性能运行性能网络性能 这三个维度分析数据,开发者可以根据客户端系统、机型、网络环境和访问来源等条件做精细化分析,非常具有考量价值。

小程序助手性能分析

其中,启动总耗时 = 小程序环境初始化 + 代码包加载 + 代码执行 + 渲染耗时

第三方测速系统

很多时候,宏观的耗时统计对于性能瓶颈点分析往往是杯水车薪,作用甚少,我们需要更细致地针对某个页面某些关键节点作测速统计,排查出暴露性能问题的代码区块,才能更有效地针对性优化。京喜小程序使用的是内部自研的测速系统,支持对地区、运营商、网络、客户端系统等多条件筛选,同时也支持数据可视化、同比分析数据等能力。京喜首页主要围绕 页面 onLoadonReady数据加载完成首屏渲染完成各业务组件首次渲染完成 等几个关键节点统计测速上报,旨在全链路监控性能表现。

内部测速系统

另外,微信为开发者提供了 测速系统,也支持针对客户端系统、网络类型、用户地区等维度统计数据,有兴趣的可以尝试。

了解小程序底层架构

为了更好地为小程序制订性能优化措施,我们有必要先了解小程序的底层架构,以及与 web 浏览器的差异性。

微信小程序是大前端跨平台技术的其中一种产物,与当下其他热门的技术 React Native、Weex、Flutter 等不同,小程序的最终渲染载体依然是浏览器内核,而不是原生客户端。

而对于传统的网页来说,UI 渲染和 JS 脚本是在同一个线程中执行,所以经常会出现 “阻塞” 行为。微信小程序基于性能的考虑,启用了双线程模型

  • 视图层:也就是 webview 线程,负责启用不同的 webview 来渲染不同的小程序页面;
  • 逻辑层:一个单独的线程执行 JS 代码,可以控制视图层的逻辑;

双线程模型图

上图来自小程序官方开发指南

然而,任何线程间的数据传输都是有延时的,这意味着逻辑层和视图层间通信是异步行为。除此之外,微信为小程序提供了很多客户端原生能力,在调用客户端原生能力的过程中,微信主线程和小程序双线程之间也会发生通信,这也是一种异步行为。这种异步延时的特性会使运行环境复杂化,稍不注意,就会产出效率低下的编码。

作为小程序开发者,我们常常会被下面几个问题所困扰:

  • 小程序启动慢;
  • 白屏时间长;
  • 页面渲染慢;
  • 运行内存不足;

接下来,我们会结合小程序的底层架构分析出这些问题的根本原因,并针对性地给出解决方案。

小程序启动太慢?

小程序启动阶段,也就是如下图所示的展示加载界面的阶段。

小程序加载界面

在这个阶段中(包括启动前后的时机),微信会默默完成下面几项工作:

1. 准备运行环境:

在小程序启动前,微信会先启动双线程环境,并在线程中完成小程序基础库的初始化和预执行。

小程序基础库包括 WebView 基础库和 AppService 基础库,前者注入到视图层中,后者注入到逻辑层中,分别为所在层级提供其运行所需的基础框架能力。

2. 下载小程序代码包:

在小程序初次启动时,需要下载编译后的代码包到本地。如果启动了小程序分包,则只有主包的内容会被下载。另外,代码包会保留在缓存中,后续启动会优先读取缓存。

3. 加载小程序代码包:

小程序代码包下载好之后,会被加载到适当的线程中执行,基础库会完成所有页面的注册。

在此阶段,主包内的所有页面 JS 文件及其依赖文件都会被自动执行。

在页面注册过程中,基础库会调用页面 JS 文件的 Page 构造器方法,来记录页面的基础信息(包括初始数据、方法等)。

4. 初始化小程序首页:

在小程序代码包加载完之后,基础库会根据启动路径找到首页,根据首页的基础信息初始化一个页面实例,并把信息传递给视图层,视图层会结合 WXML 结构、WXSS 样式和初始数据来渲染界面。

综合考虑,为了节省小程序的“点点点”时间(小程序的启动动画是三个圆点循环跑马灯),除了给每位用户发一台高配 5G 手机并顺带提供千兆宽带网络之外,还可以尽量 控制代码包大小,缩小代码包的下载时间。

无用文件、函数、样式剔除

经过多次业务迭代,无可避免的会存在一些弃用的组件/页面,以及不被调用的函数、样式规则,这些冗余代码会白白占据宝贵的代码包空间。而且,目前小程序的打包会将工程下所有文件都打入代码包内,并没有做依赖分析。

因此,我们需要及时地剔除不再使用的模块,以保证代码包空间利用率保持在较高水平。通过一些工具化手段可以有效地辅助完成这一工作。

  • 文件依赖分析

在小程序中,所有页面的路径都需要在小程序代码根目录 app.json 中被声明,类似地,自定义组件也需要在页面配置文件 page.json 中被声明。另外,WXML、WXSS 和 JS 的模块化都需要特定的关键字来声明依赖引用关系。

WXML 中的 importinclude

<!-- A.wxml -->
<template name='A'>
  <text>{{text}}</text>
</template>

<!-- B.wxml -->
<import data-original="A.wxml"/>
<template is="A" data="{{text: 'B'}}"/>
<!-- A.wxml -->
<text> A </text>

<!-- B.wxml -->
<include data-original="A.wxml"/>
<text> B </text>

WXSS 中的 @import

@import './A.wxss'

JS 中的 require/import

const A = require('./A')

所以,可以说小程序里的所有依赖模块都是有迹可循的,我们只需要利用这些关键字信息递归查找,遍历出文件依赖树,然后把没用的模块剔除掉。

  • JS、CSS Tree-Shaking

JS Tree-Shaking 的原理就是借助 Babel 把代码编译成抽象语法树(AST),通过 AST 获取到函数的调用关系,从而把未被调用的函数方法剔除掉。不过这需要依赖 ES module,而小程序最开始是遵循 CommonJS 规范的,这意味着是时候来一波“痛并快乐着”的改造了。

而 CSS 的 Tree-Shaking 可以利用 PurifyCSS 插件来完成。关于这两项技术,有兴趣的可以“谷歌一下”,这里就不铺开细讲了。

题外,京东的小程序团队已经把这一系列工程化能力集成在一套 CLI 工具中,有兴趣的可以看看这篇分享:小程序工程化探索

减少代码包中的静态资源文件

小程序代码包最终会经过 GZIP 压缩放在 CDN 上,但 GZIP 压缩对于图片资源来说效果非常低。如 JPGPNG 等格式文件,本身已经被压缩过了,再使用 GZIP 压缩有可能体积更大,得不偿失。所以,除了部分用于容错的图片必须放在代码包(譬如网络异常提示)之外,建议开发者把图片、视频等静态资源都放在 CDN 上。

需要注意,Base64 格式本质上是长字符串,和 CDN 地址比起来也会更占空间。

逻辑后移,精简业务逻辑

这是一个 “痛并快乐着” 的优化措施。“痛” 是因为需要给后台同学提改造需求,分分钟被打;“快乐” 则是因为享受删代码的过程,而且万一出 Bug 也不用背锅了...(开个玩笑)

通过让后台承担更多的业务逻辑,可以节省小程序前端代码量,同时线上问题还支持紧急修复,不需要经历小程序的提审、发布上线等繁琐过程。

总结得出,一般不涉及前端计算的展示类逻辑,都可以适当做后移。譬如京喜首页中的幕帘弹窗(如下图)逻辑,这里共有 10+ 种弹窗类型,以前的做法是前端从接口拉取 10+ 个不同字段,根据优先级和 “是否已展示”(该状态存储在本地缓存) 来决定展示哪一种,最后代码大概是这样的:

// 检查每种弹窗类型是否已展示
Promise.all([
  check(popup_1),
  check(popup_2),
  // ...
  check(popup_n)
]).then(result => {
  // 优先级排序
  const queue = [{
    show: result.popup_1
    data: data.popup_1
  }, {
    show: result.popup_2
    data: data.popup_2
  }, 
  // ...
  {
    show: result.popup_n
    data: data.popup_n
  }]
})

逻辑后移之后,前端只需负责拿幕帘字段做展示就可以了,代码变成这样:

this.setData({
  popup: data.popup
})

首页幕帘弹窗

复用模板插件

京喜首页作为电商系统的门户,需要应对各类频繁的营销活动、升级改版等,同时也要满足不同用户属性的界面个性化需求(俗称 “千人千面”)。如何既能减少为应对多样化场景而产生的代码量,又可以提升研发效率,成为燃眉之急。

类似于组件复用的理念,我们需要提供更丰富的可配置能力,实现更高的代码复用度。参考小时候很喜欢玩的 “乐高” 积木玩具,我们把首页模块的模板元素作颗粒度更细的划分,根据样式和功能抽象出一块块“积木”原料(称为插件元素)。当首页模块在处理接口数据时,会启动插件引擎逐个装载插件,最终输出个性化的模板样式,整个流程就好比堆积木。当后续产品/运营需要新增模板时,只要在插件库中挑选插件排列组合即可,不需要额外新增/修改组件内容,也更不会产生难以维护的 if / else 逻辑,so easy~

当然,要完成这样的插件化改造免不了几个先决条件:

  • 用户体验设计的统一。如果设计风格总是天差地别的,强行插件化只会成为累赘。
  • 服务端接口的统一。同上,如果得浪费大量的精力来兼容不同模块间的接口字段差异,将会非常蛋疼。

下面为大家提供部分例程来辅助理解。其中,use 方法会接受各类处理钩子最终拼接出一个 Function,在对应模块处理数据时会被调用。

// bi.helper.js

/**
 * 插件引擎
 * @param {function} options.formatName 标题处理钩子
 * @param {function} options.validList 数据校验器钩子
 */ 
const use = options => data => format(data)

/**
 * 预置插件库
 */ 
nameHelpers = {
  text: data => data.text,
  icon: data => data.icon
}
listHelpers = {
  single: list => list.slice(0, 1),
  double: list => list.slice(0, 2)
}

/**
 * “堆积木”
 */
export default {
  1000: use({
    formatName: nameHelpers.text,
    validList: listHelpers.single
  }),

  1001: use({
    formatName: nameHelpers.icon,
    validList: listHelpers.double
  })
}
<!-- bi.wxml -->
<!-- 各模板节点实现 -->
<template name="renderName">
  <view wx:if="{{type === 'text'}}"> text </view>
  <view wx:elif="{{type === 'icon'}}"> icon </view>
</template>

<view class="bi__name">
  <template is="renderName" data="{{...data.name}"/>
</view>
// bi.js
Component({
  ready() {
    // 根据 tpl 值选择解析函数
    const formatData = helper[data.tpl]
    this.setData({
      data: formatData(data)
    })
  }
})

分包加载

小程序启动时只会下载主包/独立分包,启用分包可以有效减少下载时间。(独立)分包需要遵循一些原则,详细的可以看官方文档:

部分页面 h5 化

小程序提供了 web-view 组件,支持在小程序环境内访问网页。当实在无法在小程序代码包中腾出多余空间时,可以考虑降级方案 —— 把部分页面 h5 化。

小程序和 h5 的通信可以通过 JSSDK 或 postMessage 通道来实现,详见 小程序开发文档

白屏时间过长?

白屏阶段,是指小程序代码包下载完(也就是启动界面结束)之后,页面完成首屏渲染的这一阶段,也就是 FMP (首次有效绘制)。

FMP 没法用标准化的指标定义,但对于大部分小程序来说,页面首屏展示的内容都需要依赖服务端的接口数据,那么影响白屏加载时间的主要由这两个元素构成:

  • 网络资源加载时间
  • 渲染时间

启用本地缓存

小程序提供了读写本地缓存的接口,数据存储在设备硬盘上。由于本地 I/O 读写(毫秒级)会比网络请求(秒级)要快很多,所以在用户访问页面时,可以优先从缓存中取上一次接口调用成功的数据来渲染视图,待网络请求成功后再覆盖最新数据重新渲染。除此之外,缓存数据还可以作为兜底数据,避免出现接口请求失败时页面空窗,一石二鸟。

但并非所有场景都适合缓存策略,譬如对数据即时性要求非常高的场景(如抢购入口)来说,展示老数据可能会引发一些问题。

小程序默认会按照 不同小程序不同微信用户 这两个维度对缓存空间进行隔离。诸如京喜小程序首页也采用了缓存策略,会进一步按照 数据版本号用户属性 来对缓存进行再隔离,避免信息误展示。

数据预拉取

小程序官方为开发者提供了一个在小程序冷启动时提前拉取第三方接口的能力:数据预拉取

关于冷启动和热启动的定义可以看 这里

数据预拉取的原理其实很简单,就是在小程序启动时,微信服务器代理小程序客户端发起一个 HTTP 请求到第三方服务器来获取数据,并且把响应数据存储在本地客户端供小程序前端调取。当小程序加载完成后,只需调用微信提供的 API wx.getBackgroundFetchData 从本地缓存获取数据即可。这种做法可以充分利用小程序启动和初始化阶段的等待时间,使更快地完成页面渲染。

京喜小程序首页已经在生产环境实践过这个能力,从每日千万级的数据分析得出,预拉取使冷启动时获取到接口数据的时间节点从 2.5s 加速到 1s(提速了 60%)。虽然提升效果非常明显,但这个能力依然存在一些不成熟的地方:

  • 预拉取的数据会被强缓存

    由于预拉取的请求最终是由微信的服务器发起的,也许是出于服务器资源限制的考虑,预拉取的数据会缓存在微信本地一段时间,缓存失效后才会重新发起请求。经过真机实测,在微信购物入口冷启动京喜小程序的场景下,预拉取缓存存活了 30 分钟以上,这对于数据实时性要求比较高的系统来说是非常致命的。

  • 请求体和响应体都无法被拦截

    由于请求第三方服务器是从微信的服务器发起的,而不是从小程序客户端发起的,所以本地代理无法拦截到这一次真实请求,这会导致开发者无法通过拦截请求的方式来区分获取线上环境和开发环境的数据,给开发调试带来麻烦。

    小程序内部接口的响应体类型都是 application/octet-stream,即数据格式未知,使本地代理无法正确解析。

  • 微信服务器发起的请求没有提供区分线上版和开发版的参数,且没有提供用户 IP 等信息

如果这几个问题点都不会影响到你的场景,那么可以尝试开启预拉取能力,这对于小程序首屏渲染速度是质的提升。

跳转时预拉取

为了尽快获取到服务端数据,比较常见的做法是在页面 onLoad 钩子被触发时发起网络请求,但其实这并不是最快的方式。从发起页面跳转,到下一个页面 onLoad 的过程中,小程序需要完成一些环境初始化及页面实例化的工作,耗时大概为 300 ~ 400 毫秒。

实际上,我们可以在发起跳转前(如 wx.navigateTo 调用前),提前请求下一个页面的主接口并存储在全局 Promise 对象中,待下个页面加载完成后从 Promise 对象中读取数据即可。

这也是双线程模型所带来的优势之一,不同于多页面 web 应用在页面跳转/刷新时就销毁掉 window 对象。

分包预下载

如果开启了分包加载能力,在用户访问到分包内某个页面时,小程序才会开始下载对应的分包。当处于分包下载阶段时,页面会维持在 “白屏” 的启动态,这用户体验是比较糟糕的。

幸好,小程序提供了 分包预下载 能力,开发者可以配置进入某个页面时预下载可能会用到的分包,避免在页面切换时僵持在 “白屏” 态。

非关键渲染数据延迟请求

这是关键渲染路径优化的其中一个思路,从缩短网络请求时延的角度加快首屏渲染完成时间。

关键渲染路径(Critical Rendering Path) 是指在完成首屏渲染的过程中必须发生的事件。

以京喜小程序如此庞大的小程序项目为例,每个模块背后都可能有着海量的后台服务作支撑,而这些后台服务间的通信和数据交互都会存在一定的时延。我们根据京喜首页的页面结构,把所有模块划分成两类:主体模块(导航、商品轮播、商品豆腐块等)和 非主体模块(幕帘弹窗、右侧挂件等)。

在初始化首页时,小程序会发起一个聚合接口请求来获取主体模块的数据,而非主体模块的数据则从另一个接口获取,通过拆分的手段来降低主接口的调用时延,同时减少响应体的数据量,缩减网络传输时间。

京喜首页浮层模块

分屏渲染

这也是关键渲染路径优化思路之一,通过延迟非关键元素的渲染时机,为关键渲染路径腾出资源。

类似上一条措施,继续以京喜小程序首页为例,我们在 主体模块 的基础上再度划分出 首屏模块(商品豆腐块以上部分) 和 非首屏模块(商品豆腐块及以下部分)。当小程序获取到主体模块的数据后,会优先渲染首屏模块,在所有首屏模块都渲染完成后才会渲染非首屏模块和非主体模块,以此确保首屏内容以最快速度呈现。

京喜首页分屏渲染

为了更好地呈现效果,上面 gif 做了降速处理

接口聚合,请求合并

在小程序中,发起网络请求是通过 wx.request 这个 API。我们知道,在 web 浏览器中,针对同一域名的 HTTP 并发请求数是有限制的;在小程序中也有类似的限制,但区别在于不是针对域名限制,而是针对 API 调用:

  • wx.request (HTTP 连接)的最大并发限制是 10 个;
  • wx.connectSocket (WebSocket 连接)的最大并发限制是 5 个;

超出并发限制数目的 HTTP 请求将会被阻塞,需要在队列中等待前面的请求完成,从而一定程度上增加了请求时延。因此,对于职责类似的网络请求,最好采用节流的方式,先在一定时间间隔内收集数据,再合并到一个请求体中发送给服务端。

图片资源优化

图片资源一直是移动端系统中抢占大流量的部分,尤其是对于电商系统。优化图片资源的加载可以有效地加快页面响应时间,提升首屏渲染速度。

  • 使用 WebP 格式

WebP 是 Google 推出的一种支持有损/无损压缩的图片文件格式,得益于更优的图像数据压缩算法,其与 JPG、PNG 等格式相比,在肉眼无差别的图片质量前提下具有更小的图片体积(据官方说明,WebP 无损压缩体积比 PNG 小 26%,有损压缩体积比 JPEG 小 25-34%)。

小程序的 image 组件 支持 JPG、PNG、SVG、WEBP、GIF 等格式。
  • 图片裁剪&降质

鉴于移动端设备的分辨率是有上限的,很多图片的尺寸常常远大于页面元素尺寸,这非常浪费网络资源(一般图片尺寸 2 倍于页面元素真实尺寸比较合适)。得益于京东内部强大的图片处理服务,我们可以通过资源的命名规则和请求参数来获取服务端优化后的图片:

裁剪成 100x100 的图片:https://{host}/s100x100_jfs/{file_path}

降质 70%:https://{href}!q70

  • 图片懒加载、雪碧图(CSS Sprite)优化

这两者都是比较老生常谈的图片优化技术,这里就不打算细讲了。

小程序的 image 组件 自带 lazy-load 懒加载支持。雪碧图技术(CSS Sprite)可以参考 w3schools 的教程。

  • 降级加载大图资源

在不得不使用大图资源的场景下,我们可以适当使用 “体验换速度” 的措施来提升渲染性能。

小程序会把已加载的静态资源缓存在本地,当短时间内再次发起请求时会直接从缓存中取资源(与浏览器行为一致)。因此,对于大图资源,我们可以先呈现高度压缩的模糊图片,同时利用一个隐藏的 <image> 节点来加载原图,待原图加载完成后再转移到真实节点上渲染。整个流程,从视觉上会感知到图片从模糊到高清的过程,但与对首屏渲染的提升效果相比,这点体验落差是可以接受的。

下面为大家提供部分例程:

<!-- banner.wxml -->
<image data-original="{{url}}" />

<!-- 图片加载器 -->
<image
  style="width:0;height:0;display:none"
  data-original="{{preloadUrl}}"
  bindload="onImgLoad"
  binderror="onErrorLoad"
/>
// banner.js
Component({
  ready() {
    this.originUrl = 'https://path/to/picture'  // 图片源地址
    this.setData({
      url: compress(this.originUrl)             // 加载压缩降质的图片
      preloadUrl: this.originUrl                // 预加载原图
    })
  },
  methods: {
    onImgLoad() {
      this.setData({
        url: this.originUrl                       // 加载原图
      })
    }
  }
})
注意,具有 display: none 样式的 <image> 标签只会加载图片资源,但不渲染。

京喜首页的商品轮播模块也采用了这种降级加载方案,在首屏渲染时只会加载第一帧降质图片。以每帧原图 20~50kb 的大小计算,这一措施可以在初始化阶段节省掉几百 kb 的网络资源请求。

Banner 大图降级加载

为了更好地呈现效果,上面 gif 做了降速处理

骨架屏

一方面,我们可以从降低网络请求时延、减少关键渲染的节点数这两个角度出发,缩短完成 FMP(首次有效绘制)的时间。另一方面,我们也需要从用户感知的角度优化加载体验。

“白屏” 的加载体验对于首次访问的用户来说是难以接受的,我们可以使用尺寸稳定的骨架屏,来辅助实现真实模块占位和瞬间加载。

骨架屏目前在业界被广泛应用,京喜首页选择使用灰色豆腐块作为骨架屏的主元素,大致勾勒出各模块主体内容的样式布局。由于微信小程序不支持 SSR(服务端渲染),使动态渲染骨架屏的方案难以实现,因此京喜首页的骨架屏是通过 WXSS 样式静态渲染的。

有趣的是,京喜首页的骨架屏方案经历了 “统一管理”“(组件)独立管理” 两个阶段。出于避免对组件的侵入性考虑,最初的骨架屏是由一个完整的骨架屏组件统一管理的:

<!-- index.wxml -->
<skeleton wx:if="{{isLoading}}"></skeleton>
<block wx:else>
  页面主体
</block>

但这种做法的维护成本比较高,每次页面主体模块更新迭代,都需要在骨架屏组件中的对应节点同步更新(譬如某个模块的尺寸被调整)。除此之外,感官上从骨架屏到真实模块的切换是跳跃式的,这是因为骨架屏组件和页面主体节点之间的关系是整体条件互斥的,只有当页面主体数据 Ready(或渲染完毕)时才会把骨架屏组件销毁,渲染(或展示)主体内容。

为了使用户感知体验更加丝滑,我们把骨架屏元素拆分放到各个业务组件中,骨架屏元素的显示/隐藏逻辑由业务组件内部独立管理,这就可以轻松实现 “谁跑得快,谁先出来” 的并行加载效果。除此之外,骨架屏元素与业务组件共用一套 WXML 节点,且相关样式由公共的 sass 模块集中管理,业务组件只需要在适当的节点挂上 skeletonskeleton__block 样式块即可,极大地降低了维护成本。

<!-- banner.wxml -->
<view class="{{isLoading ? 'banner--skeleton' : ''}}">
  <view class="banner_wrapper"></view>
</view>
// banner.scss
.banner--skeleton {
  @include skeleton;
  .banner_wrapper {
    @include skeleton__block;
  }
}

京喜首页骨架屏

上面的 gif 在压缩过程有些小问题,大家可以直接访问【京喜】小程序体验骨架屏效果。

如何提升渲染性能?

当调用 wx.navigateTo 打开一个新的小程序页面时,小程序框架会完成这几步工作:

1. 准备新的 webview 线程环境,包括基础库的初始化;

2. 从逻辑层到视图层的初始数据通信;

3. 视图层根据逻辑层的数据,结合 WXML 片段构建出节点树(包括节点属性、事件绑定等信息),最终与 WXSS 结合完成页面渲染;

由于微信会提前开始准备 webview 线程环境,所以小程序的渲染损耗主要在后两者 数据通信节点树创建/更新 的流程中。相对应的,比较有效的渲染性能优化方向就是:

  • 降低线程间通信频次;
  • 减少线程间通信的数据量;
  • 减少 WXML 节点数量;

合并 setData 调用

尽可能地把多次 setData 调用合并成一次。

我们除了要从编码规范上践行这个原则,还可以通过一些技术手段降低 setData 的调用频次。譬如,把同一个时间片(事件循环)内的 setData 调用合并在一起,Taro 框架就使用了这个优化手段。

在 Taro 框架下,调用 setState 时提供的对象会被加入到一个数组中,当下一次事件循环执行的时候再把这些对象合并一起,通过 setData 传递给原生小程序。

// 小程序里的时间片 API
const nextTick = wx.nextTick ? wx.nextTick : setTimeout;

只把与界面渲染相关的数据放在 data

不难得出,setData 传输的数据量越多,线程间通信的耗时越长,渲染速度就越慢。根据微信官方测得的数据,传输时间和数据量大体上呈正相关关系:

数据传输时间与数据量关系图

上图来自小程序官方开发指南

所以,与视图层渲染无关的数据尽量不要放在 data 中,可以放在页面(组件)类的其他字段下。

应用层的数据 diff

每当调用 setData 更新数据时,会引起视图层的重新渲染,小程序会结合新的 data 数据和 WXML 片段构建出新的节点树,并与当前节点树进行比较得出最终需要更新的节点(属性)。

即使小程序在底层框架层面已经对节点树更新进行了 diff,但我们依旧可以优化这次 diff 的性能。譬如,在调用 setData 时,提前确保传递的所有新数据都是有变化的,也就是针对 data 提前做一次 diff。

Taro 框架内部做了这一层优化。在每次调用原生小程序的 setData 之前,Taro 会把最新的 state 和当前页面实例的 data 做一次 diff,筛选出有必要更新的数据再执行 setData

附 Taro 框架的 数据 diff 规则

去掉不必要的事件绑定

当用户事件(如 ClickTouch 事件等)被触发时,视图层会把事件信息反馈给逻辑层,这也是一个线程间通信的过程。但,如果没有在逻辑层中绑定事件的回调函数,通信将不会被触发。

所以,尽量减少不必要的事件绑定,尤其是像 onPageScroll 这种会被频繁触发的用户事件,会使通信过程频繁发生。

去掉不必要的节点属性

组件节点支持附加自定义数据 dataset(见下面例子),当用户事件被触发时,视图层会把事件 targetdataset 数据传输给逻辑层。那么,当自定义数据量越大,事件通信的耗时就会越长,所以应该避免在自定义数据中设置太多数据。

<!-- wxml -->
<view
  data-a='A'
  data-b='B'
  bindtap='bindViewTap'
>
  Click Me!
</view>
// js
Page({
  bindViewTap(e) {
    console.log(e.currentTarget.dataset)
  }
})

适当的组件颗粒度

小程序的组件模型与 Web Components 标准中的 ShadowDOM 非常类似,每个组件都有独立的节点树,拥有各自独立的逻辑空间(包括独立的数据、setData 调用、createSelectorQuery 执行域等)。

不难得出,如果自定义组件的颗粒度太粗,组件逻辑过重,会影响节点树构建和新/旧节点树 diff 的效率,从而影响到组件内 setData 的性能。另外,如果组件内使用了 createSelectorQuery 来查找节点,过于庞大的节点树结构也会影响查找效率。

我们来看一个场景,京喜首页的 “京东秒杀” 模块涉及到一个倒计时特性,是通过 setInterval 每秒调用 setData 来更新表盘时间。我们通过把倒计时抽离出一个基础组件,可以有效降低频繁 setData 时的性能影响。

京东秒杀

适当的组件化,既可以减小数据更新时的影响范围,又能支持复用,何乐而不为?诚然,并非组件颗粒度越细越好,组件数量和小程序代码包大小是正相关的。尤其是对于使用编译型框架(如 Taro)的项目,每个组件编译后都会产生额外的运行时代码和环境 polyfill,so,为了代码包空间,请保持理智...

事件总线,替代组件间数据绑定的通信方式

WXML 数据绑定是小程序中父组件向子组件传递动态数据的较为常见的方式,如下面例程所示:Component A 组件中的变量 ab 通过组件属性传递给 Component B 组件。在此过程中,不可避免地需要经历一次 Component A 组件的 setData 调用方可完成任务,这就会产生线程间的通信。“合情合理”,但,如果传递给子组件的数据只有一部分是与视图渲染有关呢?

<!-- Component A -->
<component-b prop-a="{{a}}" prop-b="{{b}}" />
// Component B
Component({
  properties: {
    propA: String,
    propB: String,
  },
  methods: {
    onLoad: function() {
      this.data.propA
      this.data.propB
    }
  }
})

推荐一种特定场景下非常便捷的做法:通过事件总线(EventBus),也就是发布/订阅模式,来完成由父向子的数据传递。其构成非常简单(例程只提供关键代码...):

  • 一个全局的事件调度中心

    class EventBus {
      constructor() {
        this.events = {}
      }
    
      on(key, cb) { this.events[key].push(cb) }
    
      trigger(key, args) { 
        this.events[key].forEach(function (cb) {
          cb.call(this, ...args)
        })
      }
      
      remove() {}
    }
    
    const event = new EventBus()
  • 事件订阅者

    // 子组件
    Component({
      created() {
        event.on('data-ready', (data) => { this.setData({ data }) })
      }
    })
  • 事件发布者

    // Parent
    Component({
      ready() {
        event.trigger('data-ready', data)
      }
    })

子组件被创建时事先监听数据下发事件,当父组件获取到数据后触发事件把数据传递给子组件,这整个过程都是在小程序的逻辑层里同步执行,比数据绑定的方式速度更快。

但并非所有场景都适合这种做法。像京喜首页这种具有 “数据单向传递”“展示型交互” 特性、且 一级子组件数量庞大 的场景,使用事件总线的效益将会非常高;但若是频繁 “双向数据流“ 的场景,用这种方式会导致事件交错难以维护。

题外话,Taro 框架在处理父子组件间数据传递时使用的是观察者模式,通过 Object.defineProperty 绑定父子组件关系,当父组件数据发生变化时,会递归通知所有后代组件检查并更新数据。这个通知的过程会同步触发数据 diff 和一些校验逻辑,每个组件跑一遍大概需要 5 ~ 10 ms 的时间。所以,如果组件量级比较大,整个流程下来时间损耗还是不小的,我们依旧可以尝试事件总线的方案。

组件层面的 diff

我们可能会遇到这样的需求,多个组件之间位置不固定,支持随时随地灵活配置,京喜首页也存在类似的诉求。

京喜首页主体可被划分为若干个业务组件(如搜索框、导航栏、商品轮播等),这些业务组件的顺序是不固定的,今天是搜索框在最顶部,明天有可能变成导航栏在顶部了(夸张了...)。我们不可能针对多种顺序可能性提供多套实现,这就需要用到小程序的自定义模板 <template>

实现一个支持调度所有业务组件的模板,根据后台下发的模块数组按序循环渲染模板,如下面例程所示。

<!-- index.wxml -->
<template name="render-component">
  <search-bar wx:if="{{compId === 'SearchBar'}}" floor-id="{{index}}" />
  <nav-bar wx:if="{{compId === 'NavBar'}}" floor-id="{{index}}" />
  <banner wx:if="{{compId === 'Banner'}}" floor-id="{{index}}" />
  <icon-nav wx:if="{{compId === 'IconNav'}}" floor-id="{{index}}" />
</template>

<view
  class="component-wrapper"
  wx:for="{{comps}}"
  wx:for-item="comp"
>
  <template is="render-component" data="{{...comp}}"/>
</view>
// search-bar.js
Component({
  properties: {
    floorId: Number,
  },
  created() {
    event.on('data-ready', (comps) => {
      const data = comps[this.data.floorId] // 根据楼层位置取数据
    })
  }
})

貌似非常轻松地完成需求,但值得思考的是:如果组件顺序调整了,所有组件的生命周期会发生什么变化?

假设,上一次渲染的组件顺序是 ['search-bar','nav-bar','banner', 'icon-nav'],现在需要把 nav-bar 组件去掉,调整为 ['search-bar','banner', 'icon-nav']。经实验得出,当某个组件节点发生变化时,其前面的组件不受影响,其后面的组件都会被销毁重新挂载。

原理很简单,每个组件都有各自隔离的节点树(ShadowTree),页面 body 也是一个节点树。在调整组件顺序时,小程序框架会遍历比较新/旧节点树的差异,于是发现新节点树的 nav-bar 组件节点不见了,就认为该(树)分支下从 nav-bar 节点起发生了变化,往后节点都需要重渲染。

但实际上,这里的组件顺序是没有变化的,丢失的组件按道理不应该影响到其他组件的正常渲染。所以,我们在 setData 前先进行了新旧组件列表 diff:如果 newList 里面的组件是 oldList 的子集,且相对顺序没有发生变化,则所有组件不重新挂载。除此之外,我们还要在接口数据的相应位置填充上空数据,把该组件隐藏掉,done。

通过组件 diff 的手段,可以有效降低视图层的渲染压力,如果有类似场景的朋友,也可以参考这种方案。

内存占用过高?

想必没有什么会比小程序 Crash 更影响用户体验了。

当小程序占用系统资源过高,就有可能会被系统销毁或被微信客户端主动回收。应对这种尴尬场景,除了提示用户提升硬件性能之外(譬如来京东商城买新手机),还可以通过一系列的优化手段降低小程序的内存损耗。

内存不足弹窗提示

内存预警

小程序提供了监听内存不足告警事件的 API:wx.onMemoryWarning,旨在让开发者收到告警时及时释放内存资源避免小程序 Crash。然而对于小程序开发者来说,内存资源目前是无法直接触碰的,最多就是调用 wx.reLaunch 清理所有页面栈,重载当前页面,来降低内存负荷(此方案过于粗暴,别冲动,想想就好...)。

不过内存告警的信息收集倒是有意义的,我们可以把内存告警信息(包括页面路径、客户端版本、终端手机型号等)上报到日志系统,分析出哪些页面 Crash 率比较高,从而针对性地做优化,降低页面复杂度等等。

回收后台页面计时器

根据双线程模型,小程序每一个页面都会独立一个 webview 线程,但逻辑层是单线程的,也就是所有的 webview 线程共享一个 JS 线程。以至于当页面切换到后台态时,仍然有可能抢占到逻辑层的资源,譬如没有销毁的 setIntervalsetTimeout 定时器:

// Page A
Page({
  onLoad() {
    let i = 0
    setInterval(() => { i++ }, 100)
  }
})
即使如小程序的 <swiper> 组件,在页面进入后台态时依然是会持续轮播的。

正确的做法是,在页面 onHide 的时候手动把定时器清理掉,有必要时再在 onShow 阶段恢复定时器。坦白讲,区区一个定时器回调函数的执行,对于系统的影响应该是微不足道的,但不容忽视的是回调函数里的代码逻辑,譬如在定时器回调里持续 setData 大量数据,这就非常难受了...

避免频发事件中的重度内存操作

我们经常会遇到这样的需求:广告曝光、图片懒加载、导航栏吸顶等等,这些都需要我们在页面滚动事件触发时实时监听元素位置或更新视图。在了解小程序的双线程模型之后不难发现,页面滚动时 onPageScroll 被频发触发,会使逻辑层和视图层发生持续通信,若这时候再 “火上浇油” 调用 setData 传输大量数据,会导致内存使用率快速上升,使页面卡顿甚至 “假死”。所以,针对频发事件的监听,我们最好遵循以下原则:

  • onPageScroll 事件回调使用节流;
  • 避免 CPU 密集型操作,譬如复杂的计算;
  • 避免调用 setData,或减小 setData 的数据量;
  • 尽量使用 IntersectionObserver 来替代 SelectorQuery,前者对性能影响更小;

大图、长列表优化

小程序官方文档 描述,大图片和长列表图片在 iOS 中会引起 WKWebView 的回收,导致小程序 Crash。

对于大图片资源(譬如满屏的 gif 图)来说,我们只能尽可能对图片进行降质或裁剪,当然不使用是最好的。

对于长列表,譬如瀑布流,这里提供一种思路:我们可以利用 IntersectionObserver 监听长列表内组件与视窗之间的相交状态,当组件距离视窗大于某个临界点时,销毁该组件释放内存空间,并用等尺寸的骨架图占坑;当距离小于临界点时,再取缓存数据重新加载该组件。

然而无可避免地,当用户快速滚动长列表时,被销毁的组件可能来不及加载完,视觉上就会出现短暂的白屏。我们可以适当地调整销毁阈值,或者优化骨架图的样式来尽可能提升体验感。

小程序官方提供了一个 长列表组件,可以通过 npm 包的方式引入,有兴趣的可以尝试。

总结

结合上述的种种方法论,京喜小程序首页进行全方位升级改造之后给出了答卷:

1. Audits 审计工具的性能得分 86

2. 优化后的首屏渲染完成时间(FMP):

优化后的首屏渲染时间

3. 优化前后的测速数据对比:

优化前后的测速数据对比

然而,业务迭代在持续推进,多样化的用户场景徒增不减,性能优化将成为我们日常开发中挥之不去的原则和主题。本文以微信小程序开发中与性能相关的问题为出发点,基于小程序的底层框架原理,探究小程序性能体验提升的各种可能性,希望能为各位小程序开发者带来参考价值。

参考


欢迎关注凹凸实验室博客:aotu.io

或者关注凹凸实验室公众号(AOTULabs),不定时推送文章:

欢迎关注凹凸实验室公众号

查看原文

布利丹牵驴子 赞了文章 · 2020-03-30

京喜小程序的高性能打造之路

背景

京喜小程序自去年双十一上线微信购物一级入口后,时刻迎接着亿级用户量的挑战,细微的体验细节都有可能被无限放大,为此,“极致的页面性能”、“友好的产品体验” 和 “稳定的系统服务” 成为了我们开发团队的最基本执行原则。

首页作为小程序的门户,其性能表现和用户留存率息息相关。因此,我们对京喜首页进行了一次全方位的升级改造,从加载、渲染和感知体验几大维度深挖小程序的性能可塑性。

除此之外,京喜首页在微信小程序、H5、APP 三端都有落地场景,为了提高研发效率,我们使用了 Taro 框架实现多端统一,因此下文中有部分内容是和 Taro 框架息息相关的。

怎么定义高性能?

提起互联网应用性能这个词,很多人在脑海中的词法解析就是,“是否足够快?”,似乎加载速度成为衡量系统性能的唯一指标。但这其实是不够准确的,试想一下,如果一个小程序加载速度非常快,用户花费很短时间就能看到页面的主体内容,但此时搜索框却无法输入内容,功能无法被流畅使用,用户可能就不会关心页面渲染有多快了。所以,我们不应该单纯考虑速度指标而忽略用户的感知体验,而应该全方位衡量用户在使用过程中能感知到的与应用加载相关的每个节点。

谷歌为 Web 应用定义了以用户为中心的性能指标体系,每个指标都与用户体验节点息息相关:

体验指标
页面能否正常访问?首次内容绘制 (First Contentful Paint, FCP)
页面内容是否有用?首次有效绘制 (First Meaningful Paint, FMP)
页面功能是否可用?可交互时间 (Time to Interactive, TTI)

其中,“是否有用?” 这个问题是非常主观的,对于不同场景的系统可能会有完全不一样的回答,所以 FMP 是一个比较模糊的概念指标,不存在规范化的数值衡量。

小程序作为一个新的内容载体,衡量指标跟 Web 应用是非常类似的。对于大多数小程序而言,上述指标对应的含义为:

  • FCP:白屏加载结束;
  • FMP:首屏渲染完成;
  • TTI:所有内容加载完成;

综上,我们已基本确定了高性能的概念指标,接下来就是如何利用数值指标来描绘性能表现。

小程序官方性能指标

小程序官方针对小程序性能表现制订了权威的数值指标,主要围绕 渲染表现setData 数据量元素节点数网络请求延时 这几个维度来给予定义(下面只列出部分关键指标):

  • 首屏时间不超过 5 秒;
  • 渲染时间不超过 500ms;
  • 每秒调用 setData 的次数不超过 20 次;
  • setData 的数据在 JSON.stringify 后不超过 256kb;
  • 页面 WXML 节点少于 1000 个,节点树深度少于 30 层,子节点数不大于 60 个;
  • 所有网络请求都在 1 秒内返回结果;
详见 小程序性能评分规则

我们应该把这一系列的官方指标作为小程序的性能及格线,不断地打磨和提升小程序的整体体验,降低用户流失率。另外,这些指标会直接作为小程序体验评分工具的性能评分规则(体验评分工具会根据这些规则的权重和求和公式计算出体验得分)。

我们团队内部在官方性能指标的基础上,进一步浓缩优化指标系数,旨在对产品体验更高要求:

  • 首屏时间不超过 2.5 秒;
  • setData 的数据量不超过 100kb;
  • 所有网络请求都在 1 秒内返回结果;
  • 组件滑动、长列表滚动无卡顿感;

体验评分工具

小程序提供了 体验评分工具(Audits 面板) 来测量上述的指标数据,其集成在开发者工具中,在小程序运行时实时检查相关问题点,并为开发者给出优化建议。

体验评分面板

以上截图均来自小程序官方文档

体验评分工具是目前检测小程序性能问题最直接有效的途径,我们团队已经把体验评分作为页面/组件是否能达到精品门槛的重要考量手段之一。

小程序后台性能分析

我们知道,体验评分工具是在本地运行小程序代码时进行分析,但性能数据往往需要在真实环境和大数据量下才更有说服力。恰巧,小程序管理平台小程序助手 为开发者提供了大量的真实数据统计。其中,性能分析面板从 启动性能运行性能网络性能 这三个维度分析数据,开发者可以根据客户端系统、机型、网络环境和访问来源等条件做精细化分析,非常具有考量价值。

小程序助手性能分析

其中,启动总耗时 = 小程序环境初始化 + 代码包加载 + 代码执行 + 渲染耗时

第三方测速系统

很多时候,宏观的耗时统计对于性能瓶颈点分析往往是杯水车薪,作用甚少,我们需要更细致地针对某个页面某些关键节点作测速统计,排查出暴露性能问题的代码区块,才能更有效地针对性优化。京喜小程序使用的是内部自研的测速系统,支持对地区、运营商、网络、客户端系统等多条件筛选,同时也支持数据可视化、同比分析数据等能力。京喜首页主要围绕 页面 onLoadonReady数据加载完成首屏渲染完成各业务组件首次渲染完成 等几个关键节点统计测速上报,旨在全链路监控性能表现。

内部测速系统

另外,微信为开发者提供了 测速系统,也支持针对客户端系统、网络类型、用户地区等维度统计数据,有兴趣的可以尝试。

了解小程序底层架构

为了更好地为小程序制订性能优化措施,我们有必要先了解小程序的底层架构,以及与 web 浏览器的差异性。

微信小程序是大前端跨平台技术的其中一种产物,与当下其他热门的技术 React Native、Weex、Flutter 等不同,小程序的最终渲染载体依然是浏览器内核,而不是原生客户端。

而对于传统的网页来说,UI 渲染和 JS 脚本是在同一个线程中执行,所以经常会出现 “阻塞” 行为。微信小程序基于性能的考虑,启用了双线程模型

  • 视图层:也就是 webview 线程,负责启用不同的 webview 来渲染不同的小程序页面;
  • 逻辑层:一个单独的线程执行 JS 代码,可以控制视图层的逻辑;

双线程模型图

上图来自小程序官方开发指南

然而,任何线程间的数据传输都是有延时的,这意味着逻辑层和视图层间通信是异步行为。除此之外,微信为小程序提供了很多客户端原生能力,在调用客户端原生能力的过程中,微信主线程和小程序双线程之间也会发生通信,这也是一种异步行为。这种异步延时的特性会使运行环境复杂化,稍不注意,就会产出效率低下的编码。

作为小程序开发者,我们常常会被下面几个问题所困扰:

  • 小程序启动慢;
  • 白屏时间长;
  • 页面渲染慢;
  • 运行内存不足;

接下来,我们会结合小程序的底层架构分析出这些问题的根本原因,并针对性地给出解决方案。

小程序启动太慢?

小程序启动阶段,也就是如下图所示的展示加载界面的阶段。

小程序加载界面

在这个阶段中(包括启动前后的时机),微信会默默完成下面几项工作:

1. 准备运行环境:

在小程序启动前,微信会先启动双线程环境,并在线程中完成小程序基础库的初始化和预执行。

小程序基础库包括 WebView 基础库和 AppService 基础库,前者注入到视图层中,后者注入到逻辑层中,分别为所在层级提供其运行所需的基础框架能力。

2. 下载小程序代码包:

在小程序初次启动时,需要下载编译后的代码包到本地。如果启动了小程序分包,则只有主包的内容会被下载。另外,代码包会保留在缓存中,后续启动会优先读取缓存。

3. 加载小程序代码包:

小程序代码包下载好之后,会被加载到适当的线程中执行,基础库会完成所有页面的注册。

在此阶段,主包内的所有页面 JS 文件及其依赖文件都会被自动执行。

在页面注册过程中,基础库会调用页面 JS 文件的 Page 构造器方法,来记录页面的基础信息(包括初始数据、方法等)。

4. 初始化小程序首页:

在小程序代码包加载完之后,基础库会根据启动路径找到首页,根据首页的基础信息初始化一个页面实例,并把信息传递给视图层,视图层会结合 WXML 结构、WXSS 样式和初始数据来渲染界面。

综合考虑,为了节省小程序的“点点点”时间(小程序的启动动画是三个圆点循环跑马灯),除了给每位用户发一台高配 5G 手机并顺带提供千兆宽带网络之外,还可以尽量 控制代码包大小,缩小代码包的下载时间。

无用文件、函数、样式剔除

经过多次业务迭代,无可避免的会存在一些弃用的组件/页面,以及不被调用的函数、样式规则,这些冗余代码会白白占据宝贵的代码包空间。而且,目前小程序的打包会将工程下所有文件都打入代码包内,并没有做依赖分析。

因此,我们需要及时地剔除不再使用的模块,以保证代码包空间利用率保持在较高水平。通过一些工具化手段可以有效地辅助完成这一工作。

  • 文件依赖分析

在小程序中,所有页面的路径都需要在小程序代码根目录 app.json 中被声明,类似地,自定义组件也需要在页面配置文件 page.json 中被声明。另外,WXML、WXSS 和 JS 的模块化都需要特定的关键字来声明依赖引用关系。

WXML 中的 importinclude

<!-- A.wxml -->
<template name='A'>
  <text>{{text}}</text>
</template>

<!-- B.wxml -->
<import data-original="A.wxml"/>
<template is="A" data="{{text: 'B'}}"/>
<!-- A.wxml -->
<text> A </text>

<!-- B.wxml -->
<include data-original="A.wxml"/>
<text> B </text>

WXSS 中的 @import

@import './A.wxss'

JS 中的 require/import

const A = require('./A')

所以,可以说小程序里的所有依赖模块都是有迹可循的,我们只需要利用这些关键字信息递归查找,遍历出文件依赖树,然后把没用的模块剔除掉。

  • JS、CSS Tree-Shaking

JS Tree-Shaking 的原理就是借助 Babel 把代码编译成抽象语法树(AST),通过 AST 获取到函数的调用关系,从而把未被调用的函数方法剔除掉。不过这需要依赖 ES module,而小程序最开始是遵循 CommonJS 规范的,这意味着是时候来一波“痛并快乐着”的改造了。

而 CSS 的 Tree-Shaking 可以利用 PurifyCSS 插件来完成。关于这两项技术,有兴趣的可以“谷歌一下”,这里就不铺开细讲了。

题外,京东的小程序团队已经把这一系列工程化能力集成在一套 CLI 工具中,有兴趣的可以看看这篇分享:小程序工程化探索

减少代码包中的静态资源文件

小程序代码包最终会经过 GZIP 压缩放在 CDN 上,但 GZIP 压缩对于图片资源来说效果非常低。如 JPGPNG 等格式文件,本身已经被压缩过了,再使用 GZIP 压缩有可能体积更大,得不偿失。所以,除了部分用于容错的图片必须放在代码包(譬如网络异常提示)之外,建议开发者把图片、视频等静态资源都放在 CDN 上。

需要注意,Base64 格式本质上是长字符串,和 CDN 地址比起来也会更占空间。

逻辑后移,精简业务逻辑

这是一个 “痛并快乐着” 的优化措施。“痛” 是因为需要给后台同学提改造需求,分分钟被打;“快乐” 则是因为享受删代码的过程,而且万一出 Bug 也不用背锅了...(开个玩笑)

通过让后台承担更多的业务逻辑,可以节省小程序前端代码量,同时线上问题还支持紧急修复,不需要经历小程序的提审、发布上线等繁琐过程。

总结得出,一般不涉及前端计算的展示类逻辑,都可以适当做后移。譬如京喜首页中的幕帘弹窗(如下图)逻辑,这里共有 10+ 种弹窗类型,以前的做法是前端从接口拉取 10+ 个不同字段,根据优先级和 “是否已展示”(该状态存储在本地缓存) 来决定展示哪一种,最后代码大概是这样的:

// 检查每种弹窗类型是否已展示
Promise.all([
  check(popup_1),
  check(popup_2),
  // ...
  check(popup_n)
]).then(result => {
  // 优先级排序
  const queue = [{
    show: result.popup_1
    data: data.popup_1
  }, {
    show: result.popup_2
    data: data.popup_2
  }, 
  // ...
  {
    show: result.popup_n
    data: data.popup_n
  }]
})

逻辑后移之后,前端只需负责拿幕帘字段做展示就可以了,代码变成这样:

this.setData({
  popup: data.popup
})

首页幕帘弹窗

复用模板插件

京喜首页作为电商系统的门户,需要应对各类频繁的营销活动、升级改版等,同时也要满足不同用户属性的界面个性化需求(俗称 “千人千面”)。如何既能减少为应对多样化场景而产生的代码量,又可以提升研发效率,成为燃眉之急。

类似于组件复用的理念,我们需要提供更丰富的可配置能力,实现更高的代码复用度。参考小时候很喜欢玩的 “乐高” 积木玩具,我们把首页模块的模板元素作颗粒度更细的划分,根据样式和功能抽象出一块块“积木”原料(称为插件元素)。当首页模块在处理接口数据时,会启动插件引擎逐个装载插件,最终输出个性化的模板样式,整个流程就好比堆积木。当后续产品/运营需要新增模板时,只要在插件库中挑选插件排列组合即可,不需要额外新增/修改组件内容,也更不会产生难以维护的 if / else 逻辑,so easy~

当然,要完成这样的插件化改造免不了几个先决条件:

  • 用户体验设计的统一。如果设计风格总是天差地别的,强行插件化只会成为累赘。
  • 服务端接口的统一。同上,如果得浪费大量的精力来兼容不同模块间的接口字段差异,将会非常蛋疼。

下面为大家提供部分例程来辅助理解。其中,use 方法会接受各类处理钩子最终拼接出一个 Function,在对应模块处理数据时会被调用。

// bi.helper.js

/**
 * 插件引擎
 * @param {function} options.formatName 标题处理钩子
 * @param {function} options.validList 数据校验器钩子
 */ 
const use = options => data => format(data)

/**
 * 预置插件库
 */ 
nameHelpers = {
  text: data => data.text,
  icon: data => data.icon
}
listHelpers = {
  single: list => list.slice(0, 1),
  double: list => list.slice(0, 2)
}

/**
 * “堆积木”
 */
export default {
  1000: use({
    formatName: nameHelpers.text,
    validList: listHelpers.single
  }),

  1001: use({
    formatName: nameHelpers.icon,
    validList: listHelpers.double
  })
}
<!-- bi.wxml -->
<!-- 各模板节点实现 -->
<template name="renderName">
  <view wx:if="{{type === 'text'}}"> text </view>
  <view wx:elif="{{type === 'icon'}}"> icon </view>
</template>

<view class="bi__name">
  <template is="renderName" data="{{...data.name}"/>
</view>
// bi.js
Component({
  ready() {
    // 根据 tpl 值选择解析函数
    const formatData = helper[data.tpl]
    this.setData({
      data: formatData(data)
    })
  }
})

分包加载

小程序启动时只会下载主包/独立分包,启用分包可以有效减少下载时间。(独立)分包需要遵循一些原则,详细的可以看官方文档:

部分页面 h5 化

小程序提供了 web-view 组件,支持在小程序环境内访问网页。当实在无法在小程序代码包中腾出多余空间时,可以考虑降级方案 —— 把部分页面 h5 化。

小程序和 h5 的通信可以通过 JSSDK 或 postMessage 通道来实现,详见 小程序开发文档

白屏时间过长?

白屏阶段,是指小程序代码包下载完(也就是启动界面结束)之后,页面完成首屏渲染的这一阶段,也就是 FMP (首次有效绘制)。

FMP 没法用标准化的指标定义,但对于大部分小程序来说,页面首屏展示的内容都需要依赖服务端的接口数据,那么影响白屏加载时间的主要由这两个元素构成:

  • 网络资源加载时间
  • 渲染时间

启用本地缓存

小程序提供了读写本地缓存的接口,数据存储在设备硬盘上。由于本地 I/O 读写(毫秒级)会比网络请求(秒级)要快很多,所以在用户访问页面时,可以优先从缓存中取上一次接口调用成功的数据来渲染视图,待网络请求成功后再覆盖最新数据重新渲染。除此之外,缓存数据还可以作为兜底数据,避免出现接口请求失败时页面空窗,一石二鸟。

但并非所有场景都适合缓存策略,譬如对数据即时性要求非常高的场景(如抢购入口)来说,展示老数据可能会引发一些问题。

小程序默认会按照 不同小程序不同微信用户 这两个维度对缓存空间进行隔离。诸如京喜小程序首页也采用了缓存策略,会进一步按照 数据版本号用户属性 来对缓存进行再隔离,避免信息误展示。

数据预拉取

小程序官方为开发者提供了一个在小程序冷启动时提前拉取第三方接口的能力:数据预拉取

关于冷启动和热启动的定义可以看 这里

数据预拉取的原理其实很简单,就是在小程序启动时,微信服务器代理小程序客户端发起一个 HTTP 请求到第三方服务器来获取数据,并且把响应数据存储在本地客户端供小程序前端调取。当小程序加载完成后,只需调用微信提供的 API wx.getBackgroundFetchData 从本地缓存获取数据即可。这种做法可以充分利用小程序启动和初始化阶段的等待时间,使更快地完成页面渲染。

京喜小程序首页已经在生产环境实践过这个能力,从每日千万级的数据分析得出,预拉取使冷启动时获取到接口数据的时间节点从 2.5s 加速到 1s(提速了 60%)。虽然提升效果非常明显,但这个能力依然存在一些不成熟的地方:

  • 预拉取的数据会被强缓存

    由于预拉取的请求最终是由微信的服务器发起的,也许是出于服务器资源限制的考虑,预拉取的数据会缓存在微信本地一段时间,缓存失效后才会重新发起请求。经过真机实测,在微信购物入口冷启动京喜小程序的场景下,预拉取缓存存活了 30 分钟以上,这对于数据实时性要求比较高的系统来说是非常致命的。

  • 请求体和响应体都无法被拦截

    由于请求第三方服务器是从微信的服务器发起的,而不是从小程序客户端发起的,所以本地代理无法拦截到这一次真实请求,这会导致开发者无法通过拦截请求的方式来区分获取线上环境和开发环境的数据,给开发调试带来麻烦。

    小程序内部接口的响应体类型都是 application/octet-stream,即数据格式未知,使本地代理无法正确解析。

  • 微信服务器发起的请求没有提供区分线上版和开发版的参数,且没有提供用户 IP 等信息

如果这几个问题点都不会影响到你的场景,那么可以尝试开启预拉取能力,这对于小程序首屏渲染速度是质的提升。

跳转时预拉取

为了尽快获取到服务端数据,比较常见的做法是在页面 onLoad 钩子被触发时发起网络请求,但其实这并不是最快的方式。从发起页面跳转,到下一个页面 onLoad 的过程中,小程序需要完成一些环境初始化及页面实例化的工作,耗时大概为 300 ~ 400 毫秒。

实际上,我们可以在发起跳转前(如 wx.navigateTo 调用前),提前请求下一个页面的主接口并存储在全局 Promise 对象中,待下个页面加载完成后从 Promise 对象中读取数据即可。

这也是双线程模型所带来的优势之一,不同于多页面 web 应用在页面跳转/刷新时就销毁掉 window 对象。

分包预下载

如果开启了分包加载能力,在用户访问到分包内某个页面时,小程序才会开始下载对应的分包。当处于分包下载阶段时,页面会维持在 “白屏” 的启动态,这用户体验是比较糟糕的。

幸好,小程序提供了 分包预下载 能力,开发者可以配置进入某个页面时预下载可能会用到的分包,避免在页面切换时僵持在 “白屏” 态。

非关键渲染数据延迟请求

这是关键渲染路径优化的其中一个思路,从缩短网络请求时延的角度加快首屏渲染完成时间。

关键渲染路径(Critical Rendering Path) 是指在完成首屏渲染的过程中必须发生的事件。

以京喜小程序如此庞大的小程序项目为例,每个模块背后都可能有着海量的后台服务作支撑,而这些后台服务间的通信和数据交互都会存在一定的时延。我们根据京喜首页的页面结构,把所有模块划分成两类:主体模块(导航、商品轮播、商品豆腐块等)和 非主体模块(幕帘弹窗、右侧挂件等)。

在初始化首页时,小程序会发起一个聚合接口请求来获取主体模块的数据,而非主体模块的数据则从另一个接口获取,通过拆分的手段来降低主接口的调用时延,同时减少响应体的数据量,缩减网络传输时间。

京喜首页浮层模块

分屏渲染

这也是关键渲染路径优化思路之一,通过延迟非关键元素的渲染时机,为关键渲染路径腾出资源。

类似上一条措施,继续以京喜小程序首页为例,我们在 主体模块 的基础上再度划分出 首屏模块(商品豆腐块以上部分) 和 非首屏模块(商品豆腐块及以下部分)。当小程序获取到主体模块的数据后,会优先渲染首屏模块,在所有首屏模块都渲染完成后才会渲染非首屏模块和非主体模块,以此确保首屏内容以最快速度呈现。

京喜首页分屏渲染

为了更好地呈现效果,上面 gif 做了降速处理

接口聚合,请求合并

在小程序中,发起网络请求是通过 wx.request 这个 API。我们知道,在 web 浏览器中,针对同一域名的 HTTP 并发请求数是有限制的;在小程序中也有类似的限制,但区别在于不是针对域名限制,而是针对 API 调用:

  • wx.request (HTTP 连接)的最大并发限制是 10 个;
  • wx.connectSocket (WebSocket 连接)的最大并发限制是 5 个;

超出并发限制数目的 HTTP 请求将会被阻塞,需要在队列中等待前面的请求完成,从而一定程度上增加了请求时延。因此,对于职责类似的网络请求,最好采用节流的方式,先在一定时间间隔内收集数据,再合并到一个请求体中发送给服务端。

图片资源优化

图片资源一直是移动端系统中抢占大流量的部分,尤其是对于电商系统。优化图片资源的加载可以有效地加快页面响应时间,提升首屏渲染速度。

  • 使用 WebP 格式

WebP 是 Google 推出的一种支持有损/无损压缩的图片文件格式,得益于更优的图像数据压缩算法,其与 JPG、PNG 等格式相比,在肉眼无差别的图片质量前提下具有更小的图片体积(据官方说明,WebP 无损压缩体积比 PNG 小 26%,有损压缩体积比 JPEG 小 25-34%)。

小程序的 image 组件 支持 JPG、PNG、SVG、WEBP、GIF 等格式。
  • 图片裁剪&降质

鉴于移动端设备的分辨率是有上限的,很多图片的尺寸常常远大于页面元素尺寸,这非常浪费网络资源(一般图片尺寸 2 倍于页面元素真实尺寸比较合适)。得益于京东内部强大的图片处理服务,我们可以通过资源的命名规则和请求参数来获取服务端优化后的图片:

裁剪成 100x100 的图片:https://{host}/s100x100_jfs/{file_path}

降质 70%:https://{href}!q70

  • 图片懒加载、雪碧图(CSS Sprite)优化

这两者都是比较老生常谈的图片优化技术,这里就不打算细讲了。

小程序的 image 组件 自带 lazy-load 懒加载支持。雪碧图技术(CSS Sprite)可以参考 w3schools 的教程。

  • 降级加载大图资源

在不得不使用大图资源的场景下,我们可以适当使用 “体验换速度” 的措施来提升渲染性能。

小程序会把已加载的静态资源缓存在本地,当短时间内再次发起请求时会直接从缓存中取资源(与浏览器行为一致)。因此,对于大图资源,我们可以先呈现高度压缩的模糊图片,同时利用一个隐藏的 <image> 节点来加载原图,待原图加载完成后再转移到真实节点上渲染。整个流程,从视觉上会感知到图片从模糊到高清的过程,但与对首屏渲染的提升效果相比,这点体验落差是可以接受的。

下面为大家提供部分例程:

<!-- banner.wxml -->
<image data-original="{{url}}" />

<!-- 图片加载器 -->
<image
  style="width:0;height:0;display:none"
  data-original="{{preloadUrl}}"
  bindload="onImgLoad"
  binderror="onErrorLoad"
/>
// banner.js
Component({
  ready() {
    this.originUrl = 'https://path/to/picture'  // 图片源地址
    this.setData({
      url: compress(this.originUrl)             // 加载压缩降质的图片
      preloadUrl: this.originUrl                // 预加载原图
    })
  },
  methods: {
    onImgLoad() {
      this.setData({
        url: this.originUrl                       // 加载原图
      })
    }
  }
})
注意,具有 display: none 样式的 <image> 标签只会加载图片资源,但不渲染。

京喜首页的商品轮播模块也采用了这种降级加载方案,在首屏渲染时只会加载第一帧降质图片。以每帧原图 20~50kb 的大小计算,这一措施可以在初始化阶段节省掉几百 kb 的网络资源请求。

Banner 大图降级加载

为了更好地呈现效果,上面 gif 做了降速处理

骨架屏

一方面,我们可以从降低网络请求时延、减少关键渲染的节点数这两个角度出发,缩短完成 FMP(首次有效绘制)的时间。另一方面,我们也需要从用户感知的角度优化加载体验。

“白屏” 的加载体验对于首次访问的用户来说是难以接受的,我们可以使用尺寸稳定的骨架屏,来辅助实现真实模块占位和瞬间加载。

骨架屏目前在业界被广泛应用,京喜首页选择使用灰色豆腐块作为骨架屏的主元素,大致勾勒出各模块主体内容的样式布局。由于微信小程序不支持 SSR(服务端渲染),使动态渲染骨架屏的方案难以实现,因此京喜首页的骨架屏是通过 WXSS 样式静态渲染的。

有趣的是,京喜首页的骨架屏方案经历了 “统一管理”“(组件)独立管理” 两个阶段。出于避免对组件的侵入性考虑,最初的骨架屏是由一个完整的骨架屏组件统一管理的:

<!-- index.wxml -->
<skeleton wx:if="{{isLoading}}"></skeleton>
<block wx:else>
  页面主体
</block>

但这种做法的维护成本比较高,每次页面主体模块更新迭代,都需要在骨架屏组件中的对应节点同步更新(譬如某个模块的尺寸被调整)。除此之外,感官上从骨架屏到真实模块的切换是跳跃式的,这是因为骨架屏组件和页面主体节点之间的关系是整体条件互斥的,只有当页面主体数据 Ready(或渲染完毕)时才会把骨架屏组件销毁,渲染(或展示)主体内容。

为了使用户感知体验更加丝滑,我们把骨架屏元素拆分放到各个业务组件中,骨架屏元素的显示/隐藏逻辑由业务组件内部独立管理,这就可以轻松实现 “谁跑得快,谁先出来” 的并行加载效果。除此之外,骨架屏元素与业务组件共用一套 WXML 节点,且相关样式由公共的 sass 模块集中管理,业务组件只需要在适当的节点挂上 skeletonskeleton__block 样式块即可,极大地降低了维护成本。

<!-- banner.wxml -->
<view class="{{isLoading ? 'banner--skeleton' : ''}}">
  <view class="banner_wrapper"></view>
</view>
// banner.scss
.banner--skeleton {
  @include skeleton;
  .banner_wrapper {
    @include skeleton__block;
  }
}

京喜首页骨架屏

上面的 gif 在压缩过程有些小问题,大家可以直接访问【京喜】小程序体验骨架屏效果。

如何提升渲染性能?

当调用 wx.navigateTo 打开一个新的小程序页面时,小程序框架会完成这几步工作:

1. 准备新的 webview 线程环境,包括基础库的初始化;

2. 从逻辑层到视图层的初始数据通信;

3. 视图层根据逻辑层的数据,结合 WXML 片段构建出节点树(包括节点属性、事件绑定等信息),最终与 WXSS 结合完成页面渲染;

由于微信会提前开始准备 webview 线程环境,所以小程序的渲染损耗主要在后两者 数据通信节点树创建/更新 的流程中。相对应的,比较有效的渲染性能优化方向就是:

  • 降低线程间通信频次;
  • 减少线程间通信的数据量;
  • 减少 WXML 节点数量;

合并 setData 调用

尽可能地把多次 setData 调用合并成一次。

我们除了要从编码规范上践行这个原则,还可以通过一些技术手段降低 setData 的调用频次。譬如,把同一个时间片(事件循环)内的 setData 调用合并在一起,Taro 框架就使用了这个优化手段。

在 Taro 框架下,调用 setState 时提供的对象会被加入到一个数组中,当下一次事件循环执行的时候再把这些对象合并一起,通过 setData 传递给原生小程序。

// 小程序里的时间片 API
const nextTick = wx.nextTick ? wx.nextTick : setTimeout;

只把与界面渲染相关的数据放在 data

不难得出,setData 传输的数据量越多,线程间通信的耗时越长,渲染速度就越慢。根据微信官方测得的数据,传输时间和数据量大体上呈正相关关系:

数据传输时间与数据量关系图

上图来自小程序官方开发指南

所以,与视图层渲染无关的数据尽量不要放在 data 中,可以放在页面(组件)类的其他字段下。

应用层的数据 diff

每当调用 setData 更新数据时,会引起视图层的重新渲染,小程序会结合新的 data 数据和 WXML 片段构建出新的节点树,并与当前节点树进行比较得出最终需要更新的节点(属性)。

即使小程序在底层框架层面已经对节点树更新进行了 diff,但我们依旧可以优化这次 diff 的性能。譬如,在调用 setData 时,提前确保传递的所有新数据都是有变化的,也就是针对 data 提前做一次 diff。

Taro 框架内部做了这一层优化。在每次调用原生小程序的 setData 之前,Taro 会把最新的 state 和当前页面实例的 data 做一次 diff,筛选出有必要更新的数据再执行 setData

附 Taro 框架的 数据 diff 规则

去掉不必要的事件绑定

当用户事件(如 ClickTouch 事件等)被触发时,视图层会把事件信息反馈给逻辑层,这也是一个线程间通信的过程。但,如果没有在逻辑层中绑定事件的回调函数,通信将不会被触发。

所以,尽量减少不必要的事件绑定,尤其是像 onPageScroll 这种会被频繁触发的用户事件,会使通信过程频繁发生。

去掉不必要的节点属性

组件节点支持附加自定义数据 dataset(见下面例子),当用户事件被触发时,视图层会把事件 targetdataset 数据传输给逻辑层。那么,当自定义数据量越大,事件通信的耗时就会越长,所以应该避免在自定义数据中设置太多数据。

<!-- wxml -->
<view
  data-a='A'
  data-b='B'
  bindtap='bindViewTap'
>
  Click Me!
</view>
// js
Page({
  bindViewTap(e) {
    console.log(e.currentTarget.dataset)
  }
})

适当的组件颗粒度

小程序的组件模型与 Web Components 标准中的 ShadowDOM 非常类似,每个组件都有独立的节点树,拥有各自独立的逻辑空间(包括独立的数据、setData 调用、createSelectorQuery 执行域等)。

不难得出,如果自定义组件的颗粒度太粗,组件逻辑过重,会影响节点树构建和新/旧节点树 diff 的效率,从而影响到组件内 setData 的性能。另外,如果组件内使用了 createSelectorQuery 来查找节点,过于庞大的节点树结构也会影响查找效率。

我们来看一个场景,京喜首页的 “京东秒杀” 模块涉及到一个倒计时特性,是通过 setInterval 每秒调用 setData 来更新表盘时间。我们通过把倒计时抽离出一个基础组件,可以有效降低频繁 setData 时的性能影响。

京东秒杀

适当的组件化,既可以减小数据更新时的影响范围,又能支持复用,何乐而不为?诚然,并非组件颗粒度越细越好,组件数量和小程序代码包大小是正相关的。尤其是对于使用编译型框架(如 Taro)的项目,每个组件编译后都会产生额外的运行时代码和环境 polyfill,so,为了代码包空间,请保持理智...

事件总线,替代组件间数据绑定的通信方式

WXML 数据绑定是小程序中父组件向子组件传递动态数据的较为常见的方式,如下面例程所示:Component A 组件中的变量 ab 通过组件属性传递给 Component B 组件。在此过程中,不可避免地需要经历一次 Component A 组件的 setData 调用方可完成任务,这就会产生线程间的通信。“合情合理”,但,如果传递给子组件的数据只有一部分是与视图渲染有关呢?

<!-- Component A -->
<component-b prop-a="{{a}}" prop-b="{{b}}" />
// Component B
Component({
  properties: {
    propA: String,
    propB: String,
  },
  methods: {
    onLoad: function() {
      this.data.propA
      this.data.propB
    }
  }
})

推荐一种特定场景下非常便捷的做法:通过事件总线(EventBus),也就是发布/订阅模式,来完成由父向子的数据传递。其构成非常简单(例程只提供关键代码...):

  • 一个全局的事件调度中心

    class EventBus {
      constructor() {
        this.events = {}
      }
    
      on(key, cb) { this.events[key].push(cb) }
    
      trigger(key, args) { 
        this.events[key].forEach(function (cb) {
          cb.call(this, ...args)
        })
      }
      
      remove() {}
    }
    
    const event = new EventBus()
  • 事件订阅者

    // 子组件
    Component({
      created() {
        event.on('data-ready', (data) => { this.setData({ data }) })
      }
    })
  • 事件发布者

    // Parent
    Component({
      ready() {
        event.trigger('data-ready', data)
      }
    })

子组件被创建时事先监听数据下发事件,当父组件获取到数据后触发事件把数据传递给子组件,这整个过程都是在小程序的逻辑层里同步执行,比数据绑定的方式速度更快。

但并非所有场景都适合这种做法。像京喜首页这种具有 “数据单向传递”“展示型交互” 特性、且 一级子组件数量庞大 的场景,使用事件总线的效益将会非常高;但若是频繁 “双向数据流“ 的场景,用这种方式会导致事件交错难以维护。

题外话,Taro 框架在处理父子组件间数据传递时使用的是观察者模式,通过 Object.defineProperty 绑定父子组件关系,当父组件数据发生变化时,会递归通知所有后代组件检查并更新数据。这个通知的过程会同步触发数据 diff 和一些校验逻辑,每个组件跑一遍大概需要 5 ~ 10 ms 的时间。所以,如果组件量级比较大,整个流程下来时间损耗还是不小的,我们依旧可以尝试事件总线的方案。

组件层面的 diff

我们可能会遇到这样的需求,多个组件之间位置不固定,支持随时随地灵活配置,京喜首页也存在类似的诉求。

京喜首页主体可被划分为若干个业务组件(如搜索框、导航栏、商品轮播等),这些业务组件的顺序是不固定的,今天是搜索框在最顶部,明天有可能变成导航栏在顶部了(夸张了...)。我们不可能针对多种顺序可能性提供多套实现,这就需要用到小程序的自定义模板 <template>

实现一个支持调度所有业务组件的模板,根据后台下发的模块数组按序循环渲染模板,如下面例程所示。

<!-- index.wxml -->
<template name="render-component">
  <search-bar wx:if="{{compId === 'SearchBar'}}" floor-id="{{index}}" />
  <nav-bar wx:if="{{compId === 'NavBar'}}" floor-id="{{index}}" />
  <banner wx:if="{{compId === 'Banner'}}" floor-id="{{index}}" />
  <icon-nav wx:if="{{compId === 'IconNav'}}" floor-id="{{index}}" />
</template>

<view
  class="component-wrapper"
  wx:for="{{comps}}"
  wx:for-item="comp"
>
  <template is="render-component" data="{{...comp}}"/>
</view>
// search-bar.js
Component({
  properties: {
    floorId: Number,
  },
  created() {
    event.on('data-ready', (comps) => {
      const data = comps[this.data.floorId] // 根据楼层位置取数据
    })
  }
})

貌似非常轻松地完成需求,但值得思考的是:如果组件顺序调整了,所有组件的生命周期会发生什么变化?

假设,上一次渲染的组件顺序是 ['search-bar','nav-bar','banner', 'icon-nav'],现在需要把 nav-bar 组件去掉,调整为 ['search-bar','banner', 'icon-nav']。经实验得出,当某个组件节点发生变化时,其前面的组件不受影响,其后面的组件都会被销毁重新挂载。

原理很简单,每个组件都有各自隔离的节点树(ShadowTree),页面 body 也是一个节点树。在调整组件顺序时,小程序框架会遍历比较新/旧节点树的差异,于是发现新节点树的 nav-bar 组件节点不见了,就认为该(树)分支下从 nav-bar 节点起发生了变化,往后节点都需要重渲染。

但实际上,这里的组件顺序是没有变化的,丢失的组件按道理不应该影响到其他组件的正常渲染。所以,我们在 setData 前先进行了新旧组件列表 diff:如果 newList 里面的组件是 oldList 的子集,且相对顺序没有发生变化,则所有组件不重新挂载。除此之外,我们还要在接口数据的相应位置填充上空数据,把该组件隐藏掉,done。

通过组件 diff 的手段,可以有效降低视图层的渲染压力,如果有类似场景的朋友,也可以参考这种方案。

内存占用过高?

想必没有什么会比小程序 Crash 更影响用户体验了。

当小程序占用系统资源过高,就有可能会被系统销毁或被微信客户端主动回收。应对这种尴尬场景,除了提示用户提升硬件性能之外(譬如来京东商城买新手机),还可以通过一系列的优化手段降低小程序的内存损耗。

内存不足弹窗提示

内存预警

小程序提供了监听内存不足告警事件的 API:wx.onMemoryWarning,旨在让开发者收到告警时及时释放内存资源避免小程序 Crash。然而对于小程序开发者来说,内存资源目前是无法直接触碰的,最多就是调用 wx.reLaunch 清理所有页面栈,重载当前页面,来降低内存负荷(此方案过于粗暴,别冲动,想想就好...)。

不过内存告警的信息收集倒是有意义的,我们可以把内存告警信息(包括页面路径、客户端版本、终端手机型号等)上报到日志系统,分析出哪些页面 Crash 率比较高,从而针对性地做优化,降低页面复杂度等等。

回收后台页面计时器

根据双线程模型,小程序每一个页面都会独立一个 webview 线程,但逻辑层是单线程的,也就是所有的 webview 线程共享一个 JS 线程。以至于当页面切换到后台态时,仍然有可能抢占到逻辑层的资源,譬如没有销毁的 setIntervalsetTimeout 定时器:

// Page A
Page({
  onLoad() {
    let i = 0
    setInterval(() => { i++ }, 100)
  }
})
即使如小程序的 <swiper> 组件,在页面进入后台态时依然是会持续轮播的。

正确的做法是,在页面 onHide 的时候手动把定时器清理掉,有必要时再在 onShow 阶段恢复定时器。坦白讲,区区一个定时器回调函数的执行,对于系统的影响应该是微不足道的,但不容忽视的是回调函数里的代码逻辑,譬如在定时器回调里持续 setData 大量数据,这就非常难受了...

避免频发事件中的重度内存操作

我们经常会遇到这样的需求:广告曝光、图片懒加载、导航栏吸顶等等,这些都需要我们在页面滚动事件触发时实时监听元素位置或更新视图。在了解小程序的双线程模型之后不难发现,页面滚动时 onPageScroll 被频发触发,会使逻辑层和视图层发生持续通信,若这时候再 “火上浇油” 调用 setData 传输大量数据,会导致内存使用率快速上升,使页面卡顿甚至 “假死”。所以,针对频发事件的监听,我们最好遵循以下原则:

  • onPageScroll 事件回调使用节流;
  • 避免 CPU 密集型操作,譬如复杂的计算;
  • 避免调用 setData,或减小 setData 的数据量;
  • 尽量使用 IntersectionObserver 来替代 SelectorQuery,前者对性能影响更小;

大图、长列表优化

小程序官方文档 描述,大图片和长列表图片在 iOS 中会引起 WKWebView 的回收,导致小程序 Crash。

对于大图片资源(譬如满屏的 gif 图)来说,我们只能尽可能对图片进行降质或裁剪,当然不使用是最好的。

对于长列表,譬如瀑布流,这里提供一种思路:我们可以利用 IntersectionObserver 监听长列表内组件与视窗之间的相交状态,当组件距离视窗大于某个临界点时,销毁该组件释放内存空间,并用等尺寸的骨架图占坑;当距离小于临界点时,再取缓存数据重新加载该组件。

然而无可避免地,当用户快速滚动长列表时,被销毁的组件可能来不及加载完,视觉上就会出现短暂的白屏。我们可以适当地调整销毁阈值,或者优化骨架图的样式来尽可能提升体验感。

小程序官方提供了一个 长列表组件,可以通过 npm 包的方式引入,有兴趣的可以尝试。

总结

结合上述的种种方法论,京喜小程序首页进行全方位升级改造之后给出了答卷:

1. Audits 审计工具的性能得分 86

2. 优化后的首屏渲染完成时间(FMP):

优化后的首屏渲染时间

3. 优化前后的测速数据对比:

优化前后的测速数据对比

然而,业务迭代在持续推进,多样化的用户场景徒增不减,性能优化将成为我们日常开发中挥之不去的原则和主题。本文以微信小程序开发中与性能相关的问题为出发点,基于小程序的底层框架原理,探究小程序性能体验提升的各种可能性,希望能为各位小程序开发者带来参考价值。

参考


欢迎关注凹凸实验室博客:aotu.io

或者关注凹凸实验室公众号(AOTULabs),不定时推送文章:

欢迎关注凹凸实验室公众号

查看原文

赞 27 收藏 18 评论 1

布利丹牵驴子 赞了文章 · 2019-06-10

redux, koa, express 中间件实现对比解析

如果你有 express ,koa, redux 的使用经验,就会发现他们都有 中间件(middlewares)的概念,中间件 是一种拦截器的思想,用于在某个特定的输入输出之间添加一些额外处理,同时不影响原有操作。

最开始接触 中间件是在服务端使用 expresskoa 的时候,后来从服务端延伸到前端,看到其在redux的设计中也得到的极大的发挥。中间件的设计思想也为许多框架带来了灵活而强大的扩展性。

本文主要对比redux, koa, express 的中间件实现,为了更直观,我会抽取出三者中间件相关的核心代码,精简化,写出模拟示例。示例会保持 express, koaredux 的整体结构,尽量保持和源码一致,所以本文也会稍带讲解下express, koa, redux 的整体结构和关键实现:

示例源码地址, 可以一边看源码,一边读文章,欢迎star!

本文适合对express ,koa ,redux 都有一定了解和使用经验的开发者阅读

服务端的中间件

expresskoa 的中间件是用于处理 http 请求和响应的,但是二者的设计思路确不尽相同。大部分人了解的expresskoa的中间件差异在于:

  • express采用“尾递归”方式,中间件一个接一个的顺序执行, 习惯于将response响应写在最后一个中间件中;
  • koa的中间件支持 generator, 执行顺序是“洋葱圈”模型。

所谓的“洋葱圈”模型:

不过实际上,express 的中间件也可以形成“洋葱圈”模型,在 next 调用后写的代码同样会执行到,不过express中一般不会这么做,因为 expressresponse一般在最后一个中间件,那么其它中间件 next() 后的代码已经影响不到最终响应结果了;

express

首先看一下 express 的实现:

入口

// express.js

var proto = require('./application');
var mixin = require('merge-descriptors');

exports = module.exports = createApplication;

function createApplication() {

 
  // app 同时是一个方法,作为http.createServer的处理函数
  var app = function(req, res, next) { 
      app.handle(req, res, next)
  }
  
  mixin(app, proto, false);
  return app
}

这里其实很简单,就是一个 createApplication 方法用于创建 express 实例,要注意返回值 app 既是实例对象,上面挂载了很多方法,同时它本身也是一个方法,作为 http.createServer的处理函数, 具体代码在 application.js 中:

// application.js

var http = require('http');
var flatten = require('array-flatten');
var app = exports = module.exports = {}

app.listen = function listen() {
  var server = http.createServer(this)
  return server.listen.apply(server, arguments)
}

这里 app.listen 调用 nodejshttp.createServer 创建web服务,可以看到这里 var server = http.createServer(this) 其中 thisapp 本身, 然后真正的处理程序即 app.handle;

中间件处理

express 本质上就是一个中间件管理器,当进入到 app.handle 的时候就是对中间件进行执行的时候,所以,最关键的两个函数就是:

全局维护一个stack数组用来存储所有中间件,app.use 的实现就很简单了,可以就是一行代码 ``


// app.use
app.use = function(fn) {
    this.stack.push(fn)
}

express 的真正实现当然不会这么简单,它内置实现了路由功能,其中有 router, route, layer 三个关键的类,有了 router 就要对 path 进行分流,stack 中保存的是 layer实例,app.use 方法实际调用的是 router 实例的 use 方法, 有兴趣的可以自行去阅读。

app.handle 即对 stack 数组进行处理


app.handle = function(req, res, callback) {

    var stack = this.stack;
    var idx = 0;
    function next(err) {
        if (idx >= stack.length) {
          callback('err') 
          return;
        }
        var mid;
        while(idx < stack.length) {
          mid = stack[idx++];
          mid(req, res, next);
        }
    }
    next()
}

这里就是所谓的"尾递归调用",next 方法不断的取出stack中的“中间件”函数进行调用,同时把next 本身传递给“中间件”作为第三个参数,每个中间件约定的固定形式为 (req, res, next) => {}, 这样每个“中间件“函数中只要调用 next 方法即可传递调用下一个中间件。

之所以说是”尾递归“是因为递归函数的最后一条语句是调用函数本身,所以每一个中间件的最后一条语句需要是next()才能形成”尾递归“,否则就是普通递归,”尾递归“相对于普通”递归“的好处在于节省内存空间,不会形成深度嵌套的函数调用栈。有兴趣的可以阅读下阮老师的尾调用优化

至此,express 的中间件实现就完成了。

koa

不得不说,相比较 express 而言,koa 的整体设计和代码实现显得更高级,更精炼;代码基于ES6 实现,支持generator(async await), 没有内置的路由实现和任何内置中间件,context 的设计也很是巧妙。

整体

一共只有4个文件:

  • application.js 入口文件,koa应用实例的类
  • context.jsctx 实例,代理了很多requestresponse的属性和方法,作为全局对象传递
  • request.js koa 对原生 req 对象的封装
  • response.js koa 对原生 res 对象的封装

request.jsresponse.js 没什么可说的,任何 web 框架都会提供reqres 的封装来简化处理。所以主要看一下 context.jsapplication.js的实现

// context.js 

/**
 * Response delegation.
 */

delegate(proto, 'res')
  .method('setHeader')

/**
 * Request delegation.
 */

delegate(proto, 'req')
  .access('url')
  .setter('href')
  .getter('ip');

context 就是这类代码,主要功能就是在做代理,使用了 delegate 库。

简单说一下这里代理的含义,比如delegate(proto, 'res').method('setHeader') 这条语句的作用就是:当调用proto.setHeader时,会调用proto.res.setHeader 即,将protosetHeader方法代理到protores属性上,其它类似。

// application.js 中部分代码

constructor() {
    super()
    this.middleware = []
    this.context = Object.create(context)
}

use(fn) {
    this.middleware.push(fn)
}

listen(...args) {
    debug('listen')
    const server = http.createServer(this.callback());
    return server.listen(...args);
}

callback() {
    // 这里即中间件处理代码
    const fn = compose(this.middleware);
    
    const handleRequest = (req, res) => {
      // ctx 是koa的精髓之一, req, res上的很多方法代理到了ctx上, 基于 ctx 很多问题处理更加方便
      const ctx = this.createContext(req, res);
      return this.handleRequest(ctx, fn);
    };
    
    return handleRequest;
}

handleRequest(ctx, fnMiddleware) {
    ctx.statusCode = 404;
    const onerror = err => ctx.onerror(err);
    const handleResponse = () => respond(ctx);
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}
   

同样的在listen方法中创建 web 服务, 没有使用 express 那么绕的方式,const server = http.createServer(this.callback());this.callback()生成 web 服务的处理程序

callback 函数返回handleRequest, 所以真正的处理程序是this.handleRequest(ctx, fn)

中间件处理

构造函数 constructor 中维护全局中间件数组 this.middleware和全局的this.context 实例(源码中还有request,response对象和一些其他辅助属性)。和 express 不同,因为没有router的实现,所有this.middleware 中就是普通的”中间件“函数而非复杂的 layer 实例,

this.handleRequest(ctx, fn);ctx 为第一个参数,fn = compose(this.middleware) 作为第二个参数, handleRequest 会调用 fnMiddleware(ctx).then(handleResponse).catch(onerror); 所以中间处理的关键在compose方法, 它是一个独立的包koa-compose, 把它拿了出来看一下里面的内容:

// compose.js

'use strict'

module.exports = compose

function compose (middleware) {

  return function (context, next) {
    let index = -1
    return dispatch(0)
    function dispatch (i) {
      index = i
      let fn = middleware[i]
      if (i === middleware.length) fn = next
      if (!fn) return Promise.resolve()
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

express中的next 是不是很像,只不过他是promise形式的,因为要支持异步,所以理解起来就稍微麻烦点:每个中间件是一个async (ctx, next) => {}, 执行后返回的是一个promise, 第二个参数 next的值为 dispatch.bind(null, i + 1) , 用于传递”中间件“的执行,一个个中间件向里执行,直到最后一个中间件执行完,resolve 掉,它前一个”中间件“接着执行 await next() 后的代码,然后 resolve 掉,在不断向前直到第一个”中间件“ resolve掉,最终使得最外层的promiseresolve掉。

这里和express很不同的一点就是koa的响应的处理并不在"中间件"中,而是在中间件执行完返回的promiseresolve后:

return fnMiddleware(ctx).then(handleResponse).catch(onerror);

通过 handleResponse 最后对响应做处理,”中间件“会设置ctx.body, handleResponse也会主要处理 ctx.body ,所以 koa 的”洋葱圈“模型才会成立,await next()后的代码也会影响到最后的响应。

至此,koa的中间件实现就完成了。

redux

不得不说,redux 的设计思想和源码实现真的是漂亮,整体代码量不多,网上已经随处可见redux的源码解析,我就不细说了。不过还是要推荐一波官网对中间件部分的叙述 : redux-middleware

这是我读过的最好的说明文档,没有之一,它清晰的说明了 redux middleware 的演化过程,漂亮地演绎了一场从分析问题解决问题,并不断优化的思维过程。

总体

本文还是主要看一下它的中间件实现, 先简单说一下 redux 的核心处理逻辑, createStore 是其入口程序,工厂方法,返回一个 store 实例,store实例的最关键的方法就是 dispatch , 而 dispatch 要做的就是一件事:

currentState = currentReducer(currentState, action)

即调用reducer, 传入当前stateaction返回新的state

所以要模拟基本的 redux 执行只要实现 createStore , dispatch 方法即可。其它的内容如 bindActionCreators, combineReducers 以及 subscribe 监听都是辅助使用的功能,可以暂时不关注。

中间件处理

然后就到了核心的”中间件" 实现部分即 applyMiddleware.js

// applyMiddleware.js

import compose from './compose'

export default function applyMiddleware(...middlewares) {
  return createStore => (...args) => {
    const store = createStore(...args)
    let dispatch = () => {
      throw new Error(
        `Dispatching while constructing your middleware is not allowed. ` +
          `Other middleware would not be applied to this dispatch.`
      )
    }

    const middlewareAPI = {
      getState: store.getState,
      dispatch: (...args) => dispatch(...args)
    }
    const chain = middlewares.map(middleware => middleware(middlewareAPI))
    dispatch = compose(...chain)(store.dispatch)

    return {
      ...store,
      dispatch
    }
  }
}

redux 中间件提供的扩展是在 action 发起之后,到达 reducer 之前,它的实现思路就和expresskoa 有些不同了,它没有通过封装 store.dispatch, 在它前面添加 中间件处理程序,而是通过递归覆写 dispatch ,不断的传递上一个覆写的 dispatch 来实现。

每一个 redux 中间件的形式为 store => next => action => { xxx }

这里主要有两层函数嵌套:

  • 最外层函数接收参数store, 对应于 applyMiddleware.js 中的处理代码是 const chain = middlewares.map(middleware => middleware(middlewareAPI)), middlewareAPI 即为传入的store 。这一层是为了把 storeapi 传递给中间件使用,主要就是两个api:

    1. getState, 直接传递store.getState.
    2. dispatch: (...args) => dispatch(...args)这里的实现就很巧妙了,并不是store.dispatch, 而是一个外部的变量dispatch, 这个变量最终指向的是覆写后的dispatch, 这样做的原因在于,对于 redux-thunk 这样的异步中间件,内部调用store.dispatch 的时候仍然后走一遍所有“中间件”
  • 返回的chain就是第二层的数组,数组的每个元素都是这样一个函数next => action => { xxx }, 这个函数可以理解为 接受一个dispatch返回一个dispatch, 接受的dispatch 是后一个中间件返回的dispatch.
  • 还有一个关键函数即 compose, 主要作用是 compose(f, g, h) 返回 () => f(g(h(..args)))

现在在来理解 dispatch = compose(...chain)(store.dispatch) 就相对容易了,原生的 store.dispatch 传入最后一个“中间件”,返回一个新的 dispatch , 再向外传递到前一个中间件,直至返回最终的 dispatch, 当覆写后的dispatch 调用时,每个“中间件“的执行又是从外向内的”洋葱圈“模型。

至此,redux中间件就完成了。

其它关键点

redux 中间件的实现中还有一点实现也值得学习,为了让”中间件“只能应用一次,applyMiddleware 并不是作用在 store 实例上,而是作用在 createStore 工厂方法上。怎么理解呢?如果applyMiddleware 是这样的

(store, middlewares) => {}

那么当多次调用 applyMiddleware(store, middlewares) 的时候会给同一个实例重复添加同样的中间件。所以 applyMiddleware 的形式是

(...middlewares) => (createStore) => createStore,

这样,每一次应用中间件时都是创建一个新的实例,避免了中间件重复应用问题。

这种形式会接收 middlewares 返回一个 createStore 的高阶方法,这个方法一般被称为 createStoreenhance 方法,内部即增加了对中间件的应用,你会发现这个方法和中间件第二层 (dispatch) => dispatch 的形式一致,所以它也可以用于compose 进行多次增强。同时createStore 也有第三个参数enhance 用于内部判断,自增强。所以 redux 的中间件使用可以有两种写法:

第一种:用 applyMiddleware 返回 enhance 增强 createStore

store = applyMiddleware(middleware1, middleware2)(createStore)(reducer, initState)
第二种: createStore 接收一个 enhancer 参数用于自增强

store = createStore(reducer, initState, applyMiddleware(middleware1, middleware2))

第二种使用会显得直观点,可读性更好。

纵观 redux 的实现,函数式编程体现的淋漓尽致,中间件形式 store => next => action => { xx } 是函数柯里化作用的灵活体现,将多参数化为单参数,可以用于提前固定 store 参数,得到形式更加明确的 dispatch => dispatch,使得 compose得以发挥作用。

总结

总体而言,expresskoa 的实现很类似,都是next 方法传递进行递归调用,只不过 koapromise 形式。redux 相较前两者有些许不同,先通过递归向外覆写,形成执行时递归向里调用。

总结一下三者关键异同点(不仅限于中间件):

  1. 实例创建: express 使用工厂方法, koa是类
  2. koa 实现的语法更高级,使用ES6,支持generator(async await)
  3. koa 没有内置router, 增加了 ctx 全局对象,整体代码更简洁,使用更方便。
  4. koa 中间件的递归为 promise形式,express 使用while 循环加 next 尾递归
  5. 我更喜欢 redux 的实现,柯里化中间件形式,更简洁灵活,函数式编程体现的更明显
  6. reduxdispatch 覆写的方式进行中间件增强

最后再次附上 模拟示例源码 以供学习参考,喜欢的欢迎star, fork!

回答一个问题

有人说,express 中也可以用 async function 作为中间件用于异步处理? 其实是不可以的,因为 express 的中间件执行是同步的 while 循环,当中间件中同时包含 普通函数async 函数 时,执行顺序会打乱,先看这样一个例子:


function a() {
  console.log('a')
}

async function b() {
  console.log('b')
  await 1
  console.log('c')
  await 2
  console.log('d')
}

function f() {
    a()
    b()
    console.log('f')
}

这里的输出是 'a' > 'b' > 'f' > 'c'

在普通函数中直接调用async函数, async 函数会同步执行到第一个 await 后的代码,然后就立即返回一个promise, 等到内部所有 await 的异步完成,整个async函数执行完,promise 才会resolve掉.

所以,通过上述分析 express中间件实现, 如果用async函数做中间件,内部用await做异步处理,那么后面的中间件会先执行,等到 await 后再次调用 next 索引就会超出!,大家可以自己在这里 express async 打开注释,自己尝试一下。

2020-03-10 问题修正

express的简化模拟有误

while(idx < stack.length) {
  mid = stack[idx++];
  mid(req, res, next);
}

已经修正为


  mid = stack[idx++];
  mid(req, res, next);

所以最后那个问题的实验结果也是错误的,结论是express的中间件可以使用 async, 只要不是按洋葱圈的方式使用

查看原文

赞 5 收藏 2 评论 0

布利丹牵驴子 赞了文章 · 2019-06-09

express 源码阅读(全)

1. 简介

这篇文章主要的目的是分析理解express的源码,网络上关于源码的分析已经数不胜数,这篇文章准备另辟蹊径,仿制一个express的轮子,通过测试驱动的开发方式不断迭代,正向理解express的代码。

文章中的express源码是参考官网最新版本(v4.15.4),文章的整体思路是参考早期创作的另一篇文章,这篇算是其升级版本。

如果你准备通过本文学习express的基本原理,前提条件最好有一定的express使用经验,写过一两个基于express的应用程序,否则对于其背后的原理理解起来难以产生共鸣,不易掌握。

代码链接:https://github.com/WangZhecha...

2. 框架初始化

在仿制express框架前,首先完成两件事。

  • 确认需求。
  • 确认结构。

首先确认需求,从express的官方网站入手。网站有一个Hello world 的事例程序,想要仿制express,该程序肯定需要通过测试,将改代码复制保存到测试目录test/index.js

const express = require('express')
const app = express()

app.get('/', function (req, res) {
  res.send('Hello World!')
})

app.listen(3000, function () {
  console.log('Example app listening on port 3000!')
})

接下来,确认框架的名称以及目录结构。框架的名称叫做expross。目录结构如下:

expross
  |
  |-- lib
  |    | 
  |    |-- expross.js
  |
  |-- test
  |    |
  |    |-- index.js
  |
  |-- index.js

expross/index.js文件加载lib目录下的expross.js文件。

module.exports = require('./lib/expross');

通过测试程序前两行可以推断lib/expross.js导出结果应该是一个函数,所以在expross.js文件中添加如下代码:

function createApplication() {
  return {};
}

exports = module.exports = createApplication;

测试程序中包含两个函数,所以暂时将createApplication函数体实现如下:

function createApplication() {
    return {
        get: function() {
            console.log('expross().get function');
        },

        listen: function() {
            console.log('expross().listen function');
        }
    }
}

虽然无法得到想要的结果,但至少可以将测试程序跑通,函数的核心内容可以在接下来的步骤中不断完善。

至此,初始框架搭建完毕,修改test/index.js文件的前两行:

const expross = require('../');
const app = expross();

运行node test/index.js查看结果。

2. 第一次迭代

本节是expross的第一次迭代,主要实现的目标是将当前的测试用例功能完整实现,一共分两部分:

  • 实现http服务器。
  • 实现get路由请求。

实现http服务器比较简单,可以参考nodejs官网的实现。

const http = require('http');

const hostname = '127.0.0.1';
const port = 3000;

const server = http.createServer((req, res) => {
  res.statusCode = 200;
  res.setHeader('Content-Type', 'text/plain');
  res.end('Hello World\n');
});

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

参考该案例,实现exprosslisten函数。

listen: function(port, cb) {
    var server = http.createServer(function(req, res) {
        console.log('http.createServer...');
    });

    return server.listen(port, cb);
}

当前listen函数包含了两个参数,但是http.listen有很多重载函数,为了和http.listen一致,可以将函数设置为http.listen的“代理”,这样可以保持expross().listenhttp.listen的参数统一。

listen: function(port, cb) {
    var server = http.createServer(function(req, res) {
        console.log('http.createServer...');
    });

      //代理
    return server.listen.apply(server, arguments);
}

listen函数中,我们拦截了所有http请求,每次http请求都会打印http.createServer ...,之所以拦截http请求,是因为expross需要分析每次http请求,根据http请求的不同来处理不同的业务逻辑。

在底层:

一个http请求主要包括请求行、请求头和消息体,nodejs将常用的数据封装为http.IncomingMessage类,在上面的代码中req就是该类的一个对象。

每个http请求都会对应一个http响应。一个http响应主要包括状态行、响应头、消息体,nodejs将常用的数据封装为http.ServerResponse类,在上面的代码中res就是该类的一个对象。

不仅仅是nodejs,基本上所有的http服务框架都会包含request和response两个对象,分别代表着http的请求和响应,负责服务端和浏览器的交互。

在上层:

服务器后台代码根据http请求的不同,绑定不同的逻辑。在真正的http请求来临时,匹配这些http请求,执行与之对应的逻辑,这个过程就是web服务器基本的执行流程。

对于这些http请求的管理,有一个专有名词 —— “路由管理”,每个http请求就默认为一个路由,常见的路由区分策略包括URL、HTTP请求名词等等,但不仅仅限定这些,所有的http请求头上的参数其实都可以进行判断区分,例如使用user-agent字段判断移动端。

不同的框架对于路由的管理规则略有不同,但不管怎样,都需要一组管理http请求和业务逻辑映射的函数,测试用例中的get函数就是路由管理中的一个函数,主要负责添加get请求。

既然知道路由管理的重要,这里就创建一个router数组,负责管理所有路由映射。参考express框架,抽象出每个路由的基本属性:

  • path 请求路径,例如:/books、/books/1。
  • method 请求方法,例如:GET、POST、PUT、DELETE。
  • handle 处理函数。
var router = [{
    path: '*',
    method: '*',
    handle: function(req, res) {
        res.writeHead(200, {'Content-Type': 'text/plain'});
        res.end('404');
    }
}];

修改listen函数,将http请求拦截逻辑改为匹配router路由表,如果匹配成功,执行对应的handle函数,否则执行router[0].handle函数。

listen: function(port, cb) {
    var server = http.createServer(function(req, res) {
        
        for(var i=1,len=router.length; i<len; i++) {
            if((req.url === router[i].path || router[i].path === '*') &&
                (req.method === router[i].method || router[i].method === '*')) {
                return router[i].handle && router[i].handle(req, res);
            }
        }

        return router[0].handle && router[0].handle(req, res);
    });

    return server.listen.apply(server, arguments);
}

实现get路由请求非常简单,该函数主要是添加get请求路由,只需要将其信息加入到router数组即可。

get: function(path, fn) {
    router.push({
        path: path,
        method: 'GET',
        handle: fn
    });
}

执行测试用例,报错,提示res.send不存在。该函数并不是nodejs原生函数,这里在res上临时添加函数send,负责发送相应到浏览器。

listen: function(port, cb) {
    var server = http.createServer(function(req, res) {
        if(!res.send) {
            res.send = function(body) {
                res.writeHead(200, {
                    'Content-Type': 'text/plain'
                });
                res.end(body);
            };
        }

        ......
    });

    return server.listen.apply(server, arguments);
}

在结束这次迭代之前,拆分整理一下程序目录:

创建application.js文件,将createApplication函数中的代码转移到该文件,expross.js文件只保留引用。

var app = require('./application');

function createApplication() {
    return app;
}

exports = module.exports = createApplication;

整个目录结构如下:

expross
  |
  |-- lib
  |    | 
  |    |-- expross.js
  |    |-- application.js
  |
  |-- test
  |    |
  |    |-- index.js
  |
  |-- index.js

最后,运行node test/index.js,打开浏览器访问http://127.0.0.1:3000/

3. 第二次迭代

本节是expross的第二次迭代,主要的目标是构建一个初步的路由系统。根据上一节的改动,目前的路由是用一个router数组进行描述管理,对于router的操作有两个,分别是在application.get函数和application.listen函数,前者用于添加,后者用来处理。

按照面向对象的封装法则,接下来将路由系统的数据和路由系统的操作封装到一起定义一个 Router类负责整个路由系统的主要工作。

var Router = function() {
    this.stack = [{
        path: '*',
        method: '*',
        handle: function(req, res) {
            res.writeHead(200, {
                'Content-Type': 'text/plain'
            });
            res.end('404');
        }
    }];
};


Router.prototype.get = function(path, fn) {
    this.stack.push({
        path: path,
        method: 'GET',
        handle: fn
    });
};


Router.prototype.handle = function(req, res) {
    for(var i=1,len=this.stack.length; i<len; i++) {
        if((req.url === this.stack[i].path || this.stack[i].path === '*') &&
            (req.method === this.stack[i].method || this.stack[i].method === '*')) {
            return this.stack[i].handle && this.stack[i].handle(req, res);
        }
    }

    return this.stack[0].handle && this.stack[0].handle(req, res);
};

修改原有的application.js文件的内容。

var http = require('http');
var Router = require('./router');


exports = module.exports = {
    _router: new Router(),

    get: function(path, fn) {
        return this._router.get(path, fn);
    },

    listen: function(port, cb) {
        var self = this;

        var server = http.createServer(function(req, res) {
            if(!res.send) {
                res.send = function(body) {
                    res.writeHead(200, {
                        'Content-Type': 'text/plain'
                    });
                    res.end(body);
                };
            }

            return self._router.handle(req, res);
        });

        return server.listen.apply(server, arguments);
    }
};

这样以后路由方面的操作只和Router本身有关,与application分离,使代码更加清晰。

这个路由系统正常运行时没有问题的,但是如果路由不断的增多,this.stack数组会不断的增大,匹配的效率会不断降低,为了解决效率的问题,需要仔细分析路由的组成成分。

目前在expross中,一个路由是由三个部分构成:路径、方法和处理函数。前两者的关系并不是一对一的关系,而是一对多的关系,如:

GET books/1
PUT books/1
DELETE books/1

如果将路径一样的路由整合成一组,显然效率会提高很多,于是引入Layer的概念。

这里将Router系统中this.stack数组的每一项,代表一个Layer。每个Layer内部含有三个变量。

  • path,表示路由的路径。
  • handle,代表路由的处理函数。
  • route,代表真正的路由。

整体结构如下图所示:

------------------------------------------------
|     0     |     1     |     2     |     3     |      
------------------------------------------------
| Layer     | Layer     | Layer     | Layer     |
|  |- path  |  |- path  |  |- path  |  |- path  |
|  |- handle|  |- handle|  |- handle|  |- handle|
|  |- route |  |- route |  |- route |  |- route |
------------------------------------------------
                  router 内部

这里先创建Layer类。

function Layer(path, fn) {
    this.handle = fn;
    this.name = fn.name || '<anonymous>';
    this.path = path;
}


//简单处理
Layer.prototype.handle_request = function (req, res) {
  var fn = this.handle;

  if(fn) {
      fn(req, res);
  }
};


//简单匹配
Layer.prototype.match = function (path) {
    if(path === this.path || path === '*') {
        return true;
    }
    
    return false;
};

再次修改Router类。

var Router = function() {
    this.stack = [new Layer('*', function(req, res) {
        res.writeHead(200, {
            'Content-Type': 'text/plain'
        });
        res.end('404');        
    })];
};


Router.prototype.handle = function(req, res) {
    var self = this;

    for(var i=1,len=self.stack.length; i<len; i++) {
        if(self.stack[i].match(req.url)) {
            return self.stack[i].handle_request(req, res);
        }
    }

    return self.stack[0].handle_request(req, res);
};


Router.prototype.get = function(path, fn) {
    this.stack.push(new Layer(path, fn));
};

运行node test/index.js,访问http://127.0.0.1:3000/一切看起来很正常,但是上面的代码忽略了路由的属性method。这样的结果会导致如果测试用例存在问题:

app.put('/', function(req, res) {
    res.send('put Hello World!');
});

app.get('/', function(req, res) {
    res.send('get Hello World!');
});

程序无法分清PUT和GET的区别。

所以需要继续完善路由系统中的Layer类中的route属性,这个属性才是真正包含method属性的路由。

route的结构如下:

------------------------------------------------
|     0     |     1     |     2     |     3     |      
------------------------------------------------
| item      | item      | item      | item      |
|  |- method|  |- method|  |- method|  |- method|
|  |- handle|  |- handle|  |- handle|  |- handle|
------------------------------------------------
                  route 内部

创建Route类。

var Route = function(path) {
    this.path = path;
    this.stack = [];

    this.methods = {};
};

Route.prototype._handles_method = function(method) {
    var name = method.toLowerCase();
    return Boolean(this.methods[name]);
};

Route.prototype.get = function(fn) {
    var layer = new Layer('/', fn);
    layer.method = 'get';

    this.methods['get'] = true;
    this.stack.push(layer);

    return this;
};

Route.prototype.dispatch = function(req, res) {
    var self = this,
        method = req.method.toLowerCase();

    for(var i=0,len=self.stack.length; i<len; i++) {
        if(method === self.stack[i].method) {
            return self.stack[i].handle_request(req, res);
        }
    }
};

在上面的代码中,并没有定义前面结构图中的item对象,而是使用了Layer对象进行替代,主要是为了方便快捷,从另一种角度看,其实二者是存在很多共同点的。另外,为了利于理解,代码中只实现了GET方法,其他方法的代码实现是类似的。

既然有了Route类,接下来就改修改原有的Router类,将route集成其中。

Router.prototype.handle = function(req, res) {
    var self = this,
        method = req.method;

    for(var i=0,len=self.stack.length; i<len; i++) {
        if(self.stack[i].match(req.url) && 
            self.stack[i].route && self.stack[i].route._handles_method(method)) {
            return self.stack[i].handle_request(req, res);
        }
    }

    return self.stack[0].handle_request(req, res);
};


Router.prototype.route = function route(path) {
    var route = new Route(path);

    var layer = new Layer(path, function(req, res) {
        route.dispatch(req, res);
    });

    layer.route = route;

    this.stack.push(layer);
    
    return route;
};


Router.prototype.get = function(path, fn) {
    var route = this.route(path);
    route.get(fn);

    return this;
};

运行node test/index.js,一切看起来和原来一样。

这节内容主要是创建一个完整的路由系统,并在原始代码的基础上引入了Layer和Route两个概念,并修改了大量的代码,在结束本节前总结一下目前的信息。

首先,当前程序的目录结构如下:

expross
  |
  |-- lib
  |    | 
  |    |-- expross.js
  |    |-- application.js
  |    |-- router
  |          |
  |          |-- index.js
  |          |-- layer.js
  |          |-- route.js
  |
  |-- test
  |    |
  |    |-- index.js
  |
  |-- index.js

接着,总结一下当前expross各个部分的工作。

application代表一个应用程序,expross是一个工厂类负责创建application对象。Router代表路由组件,负责应用程序的整个路由系统。组件内部由一个Layer数组构成,每个Layer代表一组路径相同的路由信息,具体信息存储在Route内部,每个Route内部也是一个Layer对象,但是Route内部的Layer和Router内部的Layer是存在一定的差异性。

  • Router内部的Layer,主要包含path、route属性。
  • Route内部的Layer,主要包含method、handle属性。

如果一个请求来临,会现从头至尾的扫描router内部的每一层,而处理每层的时候会先对比URI,相同则扫描route的每一项,匹配成功则返回具体的信息,没有任何匹配则返回未找到。

最后,整个路由系统的结构如下:

 --------------
| Application  |                                 ---------------------------------------------------------
|     |        |        ----- -----------        |     0     |     1     |     2     |     3     |  ...  |
|     |-router | ----> |     | Layer     |       ---------------------------------------------------------
 --------------        |  0  |   |-path  |       | Layer     | Layer     | Layer     | Layer     |       |
  application          |     |   |-route | ----> |  |- method|  |- method|  |- method|  |- method|  ...  |
                       |-----|-----------|       |  |- handle|  |- handle|  |- handle|  |- handle|       |
                       |     | Layer     |       ---------------------------------------------------------
                       |  1  |   |-path  |                                  route
                       |     |   |-route |       
                       |-----|-----------|       
                       |     | Layer     |
                       |  2  |   |-path  |
                       |     |   |-route |
                       |-----|-----------|
                       | ... |   ...     |
                        ----- ----------- 
                             router

3. 第三次迭代

本节是expross的第三次迭代,主要的目标是继续完善路由系统,主要工作有部分:

  • 丰富接口,目前只支持get接口。
  • 增加路由系统的流程控制。

当前expross框架只支持get接口,具体的接口是由expross提供的,内部调用Router.get接口,而其内部是对Route.get的封装。

HTTP显然不仅仅只有GET这一个方法,还包括很多,例如:PUT、POST、DELETE等等,每个方法都单独写一个处理函数显然是冗余的,因为函数的内容除了和函数名相关外,其他都是一成不变的。根据JavaScript脚本语言语言的特性,这里可以通过HTTP的方法列表动态生成函数内容。

想要动态生成函数,首先需要确定函数名称。函数名就是nodejs中HTTP服务器支持的方法名称,可以在官方文档中获取,具体参数是http.METHODS。这个属性是在v0.11.8新增的,如果nodejs低于该版本,需要手动建立一个方法列表,具体可以参考nodejs代码。

express框架HTTP方法名的获取封装到另一个包,叫做methods,内部给出了低版本的兼容动词列表。

function getBasicNodeMethods() {
  return [
    'get',
    'post',
    'put',
    'head',
    'delete',
    'options',
    'trace',
    'copy',
    'lock',
    'mkcol',
    'move',
    'purge',
    'propfind',
    'proppatch',
    'unlock',
    'report',
    'mkactivity',
    'checkout',
    'merge',
    'm-search',
    'notify',
    'subscribe',
    'unsubscribe',
    'patch',
    'search',
    'connect'
  ];
}

知道所支持的方法名列表数组后,剩下的只需要一个for循环生成所有的函数即可。

所有的动词处理函数的核心内容都在Route中。

http.METHODS.forEach(function(method) {
    method = method.toLowerCase();
    Route.prototype[method] = function(fn) {
        var layer = new Layer('/', fn);
        layer.method = method;

        this.methods[method] = true;
        this.stack.push(layer);

        return this;
    };
});

接着修改Router。

http.METHODS.forEach(function(method) {
    method = method.toLowerCase();
    Router.prototype[method] = function(path, fn) {
        var route = this.route(path);
        route[method].call(route, fn);

        return this;
    };
});

最后修改application.js的内容。这里改动较大,重新定义了一个Application类,而不是使用字面量直接创建。

function Application() {
    this._router = new Router();
}


Application.prototype.listen = function(port, cb) {
    var self = this;

    var server = http.createServer(function(req, res) {
        self.handle(req, res);
    });

    return server.listen.apply(server, arguments);
};


Application.prototype.handle = function(req, res) {
    if(!res.send) {
        res.send = function(body) {
            res.writeHead(200, {
                'Content-Type': 'text/plain'
            });
            res.end(body);
        };
    }

    var router = this._router;
    router.handle(req, res);
};

接着增加HTTP方法函数。

http.METHODS.forEach(function(method) {
    method = method.toLowerCase();
    Application.prototype[method] = function(path, fn) {
        this._router[method].apply(this._router, arguments);
        return this;
    };
});

因为导出的是Application类,所以修改expross.js文件。

var Application = require('./application');

function createApplication() {
    var app = new Application();
    return app;
}

运行node test/index.js,走起。

如果你仔细研究路由系统的源码,会发现route本身的定义并不是像文字描述那样。例如:

增加两个同样路径的路由:

app.put('/', function(req, res) {
    res.send('put Hello World!');
});

app.get('/', function(req, res) {
    res.send('get Hello World!');
});

结果并不是想象中类似下面的结构:

                          ---------------------------------------------------------
 ----- -----------        |     0     |     1     |     2     |     3     |  ...  |
|     | Layer     |       ---------------------------------------------------------
|  0  |   |-path  |       | Layer     | Layer     | Layer     | Layer     |       |
|     |   |-route | ----> |  |- method|  |- method|  |- method|  |- method|  ...  |
|-----|-----------|       |  |- handle|  |- handle|  |- handle|  |- handle|       |
|     | Layer     |       ---------------------------------------------------------
|  1  |   |-path  |                                  route
|     |   |-route |       
|-----|-----------|       
|     | Layer     |
|  2  |   |-path  |
|     |   |-route |
|-----|-----------|
| ... |   ...     |
 ----- ----------- 
      router

而是如下的结构:

 ----- -----------        -------------
|     | Layer     | ----> | Layer     |
|  0  |   |-path  |       |  |- method|   route
|     |   |-route |       |  |- handle|
|-----|-----------|       -------------
|     | Layer     |       -------------      
|  1  |   |-path  | ----> | Layer     |
|     |   |-route |       |  |- method|   route     
|-----|-----------|       |  |- handle|        
|     | Layer     |       -------------
|  2  |   |-path  |       -------------  
|     |   |-route | ----> | Layer     |
|-----|-----------|       |  |- method|   route
| ... |   ...     |       |  |- handle| 
 ----- -----------        -------------
    router            

之所以会这样是因为路由系统存在这先后顺序的关系,如果你前面的描述结构很有可能会丢失路由顺序这个属性。既然这样,那Route的用处是在哪?

因为在express框架中,Route存储的是真正的路由信息,可以当做单独的成员使用,如果想要真正前面的所描述的结果描述,你需要这样创建你的代码:

var router = express.Router();

router.route('/users/:user_id')
.get(function(req, res, next) {
  res.json(req.user);
})
.put(function(req, res, next) {
  // just an example of maybe updating the user
  req.user.name = req.params.name;
  // save user ... etc
  res.json(req.user);
})
.post(function(req, res, next) {
  next(new Error('not implemented'));
})
.delete(function(req, res, next) {
  next(new Error('not implemented'));
});

而不是这样:

var app = expross();

app.get('/users/:user_id', function(req, res) {
    
})

.put('/users/:user_id', function(req, res) {
    
})

.post('/users/:user_id', function(req, res) {
    
})

.delete('/users/:user_id', function(req, res) {
    
});

理解了Route的使用方法,接下来就要讨论刚刚提到的顺序问题。在路由系统中,路由的处理顺序非常重要,因为路由是按照数组的方式存储的,如果遇见两个同样的路由,同样的方法名,不同的处理函数,这时候前后声明的顺序将直接影响结果(这也是express中间件存在顺序相关的原因),例如下面的例子:

app.get('/', function(req, res) {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    res.end('first');
});

app.get('/', function(req, res) {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    res.end('second');
});

上面的代码如果执行会发现永远都返回first,但是有的时候会根据前台传来的参数动态判断是否执行接下来的路由,怎样才能跳过first进入second?这就涉及到路由系统的流程控制问题。

流程控制分为主动和被动两种模式。

对于expross框架来说,路由绑定的处理逻辑、用户设置的路径参数这些都是不可靠的,在运行过程中很有可能会发生异常,被动流程控制就是当这些异常发生的时候,expross框架要担负起捕获这些异常的工作,因为如果不明确异常的发生位置,会导致js代码无法继续运行,并且无法准确的报出故障。

主动流程控制则是处理函数内部的操作逻辑,以主动调用的方式来跳转路由内部的执行逻辑。

目前express通过引入next参数的方式来解决流程控制问题。next是处理函数的一个参数,其本身也是一个函数,该函数有几种使用方式:

  • 执行下一个处理函数。执行next()
  • 报告异常。执行next(err)
  • 跳过当前Route,执行Router的下一项。执行next('route')
  • 跳过整个Router。执行next('router')

接下来,我们尝试实现以下这些需求。

首先修改最底层的Layer对象,该对象的handle_request函数是负责调用路由绑定的处理逻辑,这里添加next参数,并且增加异常捕获功能。

Layer.prototype.handle_request = function (req, res, next) {
  var fn = this.handle;

  try {
    fn(req, res, next);
  } catch (err) {
    next(err);
  }
};

接下来修改Route.dispath函数。因为涉及到内部的逻辑跳转,使用for循环按部就班不太合适,这里使用了类似递归的方式。

Route.prototype.dispatch = function(req, res, done) {
    var self = this,
        method = req.method.toLowerCase(),
        idx = 0, stack = self.stack;

    function next(err) {
        //跳过route
        if(err && err === 'route') {
            return done();
        }

        //跳过整个路由系统
        if(err && err === 'router') {
            return done(err);
        }

        //越界
        if(idx >= stack.length) {
            return done(err);
        }

        //不等枚举下一个
        var layer = stack[idx++];
        if(method !== layer.method) {
            return next(err);
        }

        if(err) {
            //主动报错
            return done(err);
        } else {
            layer.handle_request(req, res, next);
        }
    }

    next();
};

整个处理过程本质上还是一个for循环,唯一的差别就是在处理函数中用户主动调用next函数的处理逻辑。

如果用户通过next函数返回错误、routerouter这三种情况,目前统一抛给Router处理。

因为修改了dispatch函数,所以调用该函数的Router.route函数也要修改,这回直接改彻底,以后无需根据参数的个数进行调整。

Router.prototype.route = function route(path) {
    ...
    
    //使用bind方式
    var layer = new Layer(path, route.dispatch.bind(route));
    
    ...
};

接着修改Router.handle的代码,逻辑和Route.dispatch类似。

Router.prototype.handle = function(req, res, done) {
    var self = this,
        method = req.method,
        idx = 0, stack = self.stack;

    function next(err) {
        var layerError = (err === 'route' ? null : err);

        //跳过路由系统
        if(layerError === 'router') {
            return done(null);
        }

        if(idx >= stack.length || layerError) {
            return done(layerError);
        }

        var layer = stack[idx++];
        //匹配,执行
        if(layer.match(req.url) && layer.route &&
            layer.route._handles_method(method)) {
            return layer.handle_request(req, res, next);
        } else {
            next(layerError);
        }
    }

    next();
};

修改后的函数处理过程和原来的类似,不过有一点需要注意,当发生异常的时候,会将结果返回给上一层,而不是执行原有this.stack第0层的代码逻辑。

最后增加expross框架异常处理的逻辑。

在之前,可以移除原有this.stack的初始化代码,将

var Router = function() {
    this.stack = [new Layer('*', function(req, res) {
        res.writeHead(200, {
            'Content-Type': 'text/plain'
        });
        res.end('404');        
    })];
};

改为

var Router = function() {
    this.stack = [];
};

然后,修改Application.handle函数。

Application.prototype.handle = function(req, res) {
  
    ...
    
    var done = function finalhandler(err) {
        res.writeHead(404, {
            'Content-Type': 'text/plain'
        });

        if(err) {
            res.end('404: ' + err);    
        } else {
            var msg = 'Cannot ' + req.method + ' ' + req.url;
            res.end(msg);    
        }
    };

    var router = this._router;
    router.handle(req, res, done);
};

这里简单的将done函数处理为返回404页面,其实在express框架中,使用的是一个单独的npm包,叫做finalhandler

简单的修改一下测试用例证明一下成果。

var expross = require('../');
var app = expross();

app.get('/', function(req, res, next) {
    next();
})

.get('/', function(req, res, next) {
    next(new Error('error'));
})

.get('/', function(req, res) {
    res.send('third');
});

app.listen(3000, function() {
    console.log('Example app listening on port 3000!');
});

运行node test/index.js,访问http://127.0.0.1:3000/,结果显示:

404: Error: error

貌似目前一切都很顺利,不过还有一个需求目前被忽略了。当前处理函数的异常全部是由框架捕获,返回的信息只能是固定的404页面,对于框架使用者显然很不方便,大多数时候,我们都希望可以捕获错误,并按照一定的信息封装返回给浏览器,所以expross需要一个返回错误给上层使用者的接口。

目前和上层对接的处理函数的声明如下:

function process_fun(req, res, next) {
  
}

如果增加一个错误处理函数,按照nodejs的规则,第一个参数是错误信息,定义应该如下所示:

function process_err(err, req, res, next) {
  
}

因为两个声明的第一个参数信息是不同的,如果区分传入的处理函数是处理错误的函数还是处理正常的函数,这个是expross框架需要搞定的关键问题。

javascript中,Function.length属性可以获取传入函数指定的参数个数,这个可以当做区分二者的关键信息。

既然确定了原理,接下来直接在Layer类上增加一个专门处理错误的函数,和处理正常信息的函数区分开。

//错误处理
Layer.prototype.handle_error = function (error, req, res, next) {
  var fn = this.handle;

  //如果函数参数不是标准的4个参数,返回错误信息
  if(fn.length !== 4) {
    return next(error);
  }

  try {
    fn(error, req, res, next);
  } catch (err) {
    next(err);
  }
};

接着修改Route.dispatch函数。

Route.prototype.dispatch = function(req, res, done) {
    var self = this,
        method = req.method.toLowerCase(),
        idx = 0, stack = self.stack;

    function next(err) {
    
        ...

        if(err) {
            //主动报错
            layer.handle_error(err, req, res, next);
        } else {
            layer.handle_request(req, res, next);
        }
    }

    next();
};

当发生错误的时候,Route会一直向后寻找错误处理函数,如果找到则返回,否则执行done(err),将错误抛给Router。

对于Router.handle的修改,因为涉及到一些中间件的概念,完整的错误处理将推移到下一节完成。

本节的内容基本上完成,包括HTTP相关的动作接口的添加、路由系统的流程跳转以及Route级别的错误响应等等,涉及到路由系统剩下的一点内容暂时放到以后讲解。

4. 第四次迭代

本节是expross的第四次迭代,主要的目标是建立中间件机制并继续完善路由系统的功能。

在express中,中间件其实是一个介于web请求来临后到调用处理函数前整个流程体系中间调用的组件。其本质是一个函数,内部可以访问修改请求和响应对象,并调整接下来的处理流程。

express官方给出的解释如下:

Express 是一个自身功能极简,完全是由路由和中间件构成一个的 web 开发框架:从本质上来说,一个 Express 应用就是在调用各种中间件。

中间件(Middleware) 是一个函数,它可以访问请求对象(request object (req)), 响应对象(response object (res)), 和 web 应用中处于请求-响应循环流程中的中间件,一般被命名为 next 的变量。

中间件的功能包括:

  • 执行任何代码。
  • 修改请求和响应对象。
  • 终结请求-响应循环。
  • 调用堆栈中的下一个中间件。

如果当前中间件没有终结请求-响应循环,则必须调用 next() 方法将控制权交给下一个中间件,否则请求就会挂起。

Express 应用可使用如下几种中间件:

使用可选则挂载路径,可在应用级别或路由级别装载中间件。另外,你还可以同时装在一系列中间件函数,从而在一个挂载点上创建一个子中间件栈。

官方给出的定义其实已经足够清晰,一个中间件的样式其实就是上一节所提到的处理函数,只不过并没有正式命名。所以对于代码来说Router类中的this.stack属性内部的每个handle都是一个中间件,根据使用接口不同区别了应用级中间件路由级中间件,而四个参数的处理函数就是错误处理中间件

接下来就给expross框架添加中间件的功能。

首先是应用级中间件,其使用方法是Application类上的两种方式:Application.use 和 Application.METHOD (HTTP的各种请求方法),后者其实在前面的小节里已经实现了,前者则是需要新增的。

在前面的小节里的代码已经说明Application.METHOD内部其实是Router.METHOD的代理,Application.use同样如此。

Application.prototype.use = function(fn) {
    var path = '/',
        router = this._router;

    router.use(path, fn);

    return this;
};

因为Application.use支持可选路径,所以需要增加处理路径的重载代码。

Application.prototype.use = function(fn) {
    var path = '/',
        router = this._router;

    //路径挂载
    if(typeof fn !== 'function') {
        path = fn;
        fn = arguments[1];
    }

    router.use(path, fn);

    return this;
};

其实express框架支持的参数不仅仅这两种,但是为了便于理解剔除了一些旁枝末节,便于框架的理解。

接下来实现Router.use函数。

Router.prototype.use = function(fn) {
    var path = '/';

    //路径挂载
    if(typeof fn !== 'function') {
        path = fn;
        fn = arguments[1];
    }

    var layer = new Layer(path, fn);
    layer.route = undefined;

    this.stack.push(layer);

    return this;
};

内部代码和Application.use差不多,只不过最后不再是调用Router.use,而是直接创建一个Layer对象,将其放到this.stack数组中。

在这里段代码里可以看到普通路由和中间件的区别。普通路由放到Route中,且Router.route属性指向Route对象,Router.handle属性指向Route.dispatch函数;中间件的Router.route属性为undefined,Router.handle指向中间件处理函数,被放到Router.stack数组中。

对于路由级中间件,首先按照要求导出Router类,便于使用。

exports.Router = Router;

上面的代码添加到expross.js文件中,这样就可以按照下面的使用方式创建一个单独的路由系统。

var app = express();
var router = express.Router();

router.use(function (req, res, next) {
  console.log('Time:', Date.now());
});

现在问题来了,如果像上面的代码一样创建一个新的路由系统是无法让路由系统内部的逻辑生效的,因为这个路由系统没法添加到现有的系统中。

一种办法是增加一个专门添加新路由系统的接口,是完全是可行的,但是我更欣赏express框架的办法,这可能是Router叫做路由级中间件的原因。express将Router定义成一个特殊的中间件,而不是一个单独的类。

这样将单独创建的路由系统添加到现有的应用中的代码非常简单通用:

var router = express.Router();

// 将路由挂载至应用
app.use('/', router);

这确实是一个好方法,现在就来将expross修改成类似的样子。

首先创建一个原型对象,将现有的Router方法转移到该对象上。

var proto = {};

proto.handle = function(req, res, done) {...};
proto.route = function route(path) {...};
proto.use = function(fn) { ... };

http.METHODS.forEach(function(method) {
    method = method.toLowerCase();
    proto[method] = function(path, fn) {
        var route = this.route(path);
        route[method].call(route, fn);

        return this;
    };
});

然后创建一个中间件函数,使用Object.setPrototypeOf函数设置其原型,最后导出一个生成这些过程的函数。

module.exports = function() {
    function router(req, res, next) {
        router.handle(req, res, next);
    }

    Object.setPrototypeOf(router, proto);

    router.stack = [];
    return router;
};

修改测试用例,测试一下效果。

app.use(function(req, res, next) {
    console.log('Time:', Date.now());
    next();
});

app.get('/', function(req, res, next) {
    res.send('first');
});


router.use(function(req, res, next) {
    console.log('Time: ', Date.now());
    next();
});

router.use('/', function(req, res, next) {
    res.send('second');
});

app.use('/user', router);

app.listen(3000, function() {
    console.log('Example app listening on port 3000!');
});

结果并不理想,原有的应用程序还有两个地方需要修改。

首先是逻辑处理问题。原有的Router.handle函数中并没有处理中间件的情况,需要进一步修改。

proto.handle = function(req, res, done) {
    
    ...
    
    function next(err) {
        
        ...
        
        //注意这里,layer.route属性
        if(layer.match(req.url) && layer.route &&
            layer.route._handles_method(method)) {
            layer.handle_request(req, res, next);
        } else {
            layer.handle_error(layerError, req, res, next);
        }
    }

    next();
};

改成:

proto.handle = function(req, res, done) {

    ...

    function next(err) {
        
        ...
        
        //匹配,执行
        if(layer.match(path)) {
            if(!layer.route) {
                //处理中间件
                layer.handle_request(req, res, next);    
            } else if(layer.route._handles_method(method)) {
                //处理路由
                layer.handle_request(req, res, next);
            }    
        } else {
            layer.handle_error(layerError, req, res, next);
        }
    }

    next();
};

其次是路径匹配的问题。原有的单一路径被拆分成为不同中间的路径组合,这里判断需要多步进行,因为每个中间件只是匹配自己的路径是否通过,不过相对而言目前涉及的匹配都是全等匹配,还没有涉及到类似express框架中的正则匹配,算是非常简单了。

想要实现匹配逻辑就要清楚的知道哪段路径和哪个处理函数匹配,这里定义三个变量:

  • req.originalUrl 原始请求路径。
  • req.url 当前路径。
  • req.baseUrl 父路径。

主要修改Router.handle函数,该函数主要负责提取当前路径段,便于和事先传入的路径进行匹配。

proto.handle = function(req, res, done) {
    var self = this,
        method = req.method,
        idx = 0, stack = self.stack,
        removed = '', slashAdded = false;


    //获取当前父路径
    var parentUrl = req.baseUrl || '';
    //保存父路径
    req.baseUrl = parentUrl;
    //保存原始路径
    req.orginalUrl = req.orginalUrl || req.url;


    function next(err) {
        var layerError = (err === 'route' ? null : err);

        //如果有移除,复原原有路径
        if(slashAdded) {
            req.url = '';
            slashAdded = false;
        }


        //如果有移除,复原原有路径信息
        if(removed.length !== 0) {
            req.baseUrl = parentUrl;
            req.url = removed + req.url;
            removed = '';
        }


        //跳过路由系统
        if(layerError === 'router') {
            return done(null);
        }


        if(idx >= stack.length || layerError) {
            return done(layerError);
        }

        //获取当前路径
        var path = require('url').parse(req.url).pathname;

        var layer = stack[idx++];
        //匹配,执行
        if(layer.match(path)) {

            //处理中间件
            if(!layer.route) {
                //要移除的部分路径
                removed = layer.path;

                //设置当前路径
                req.url = req.url.substr(removed.length);
                if(req.url === '') {
                    req.url = '/' + req.url;
                    slashAdded = true;
                }

                //设置当前路径的父路径
                req.baseUrl = parentUrl + removed;

                //调用处理函数
                layer.handle_request(req, res, next);    
            } else if(layer.route._handles_method(method)) {
                //处理路由
                layer.handle_request(req, res, next);
            }    
        } else {
            layer.handle_error(layerError, req, res, next);
        }
    }

    next();
};

这段代码主要处理两种情况:

第一种,存在路由中间件的情况。如:

router.use('/1', function(req, res, next) {
    res.send('first user');
});

router.use('/2', function(req, res, next) {
    res.send('second user');
});

app.use('/users', router);

这种情况下,Router.handle顺序匹配到中间的时候,会递归调用Router.handle,所以需要保存当前的路径快照,具体路径相关信息放到req.url、req.originalUrl 和req.baseUrl 这三个参数中。

第二种,非路由中间件的情况。如:

app.get('/', function(req, res, next) {
    res.send('home');
});

app.get('/books', function(req, res, next) {
    res.send('books');
});

这种情况下,Router.handle内部主要是按照栈中的次序匹配路径即可。

改好了处理函数,还需要修改一下Layer.match这个匹配函数。目前创建Layer可能会有三种情况:

  • 不含有路径的中间件。path属性默认为/
  • 含有路径的中间件。
  • 普通路由。如果path属性为*,表示任意路径。

修改原有Layer是构造函数,增加一个fast_star 标记用来判断path是否为*。

function Layer(path, fn) {
  this.handle = fn;
  this.name = fn.name || '<anonymous>';
  this.path = undefined;

  //是否为*
  this.fast_star = (path === '*' ? true : false);
  if(!this.fast_star) {
    this.path = path;
  }
}

接着修改Layer.match匹配函数。

Layer.prototype.match = function(path) {

  //如果为*,匹配
  if(this.fast_star) {
    this.path = '';
    return true;
  }

  //如果是普通路由,从后匹配
  if(this.route && this.path === path.slice(-this.path.length)) {
    return true;
  }

  if (!this.route) {
    //不带路径的中间件
    if (this.path === '/') {
      this.path = '';
      return true;
    }

    //带路径中间件
    if(this.path === path.slice(0, this.path.length)) {
      return true;
    }
  }
  
  return false;
};

代码中一共判断四种情况,根据this.route区分中间件和普通路由,然后分开判断。

express除了普通的中间件外还要一种错误中间件,专门用来处理错误信息。该中间件的声明和上一小节最后介绍的错误处理函数是一样的,同样是四个参数分别是:err、 req、 res和 next。

目前Router.handle中,当遇见错误信息的时候,会直接通过回调函数返回错误信息,显示错误页面。

if(idx >= stack.length || layerError) {
    return done(layerError);
}

这里需要修改策略,将其改为继续调用下一个中间件,直到碰到错误中间件为止。

//没有找到
if(idx >= stack.length) {
    return done(layerError);
}

原有这一块的代码只保留判断枚举是否完成,将错误判断转移到最后执行处理函数的位置。之所以这样做是因为不管是执行处理函数,或是执行错误处理都需要进行路径匹配操作和路径分析操作,所以放到后面正好合适。

//处理中间件
if(!layer.route) {

    ...

    //调用处理函数
    if(layerError)
        layer.handle_error(layerError, req, res, next);
    else
        layer.handle_request(req, res, next);
    
} else if(layer.route._handles_method(method)) {
    //处理路由
    layer.handle_request(req, res, next);
}    

到此为止,expross的错误处理部分算是基本完成了。

路由系统和中间件两个大的概念算是全部讲解完毕,当然还有很多细节没有完善,在剩下的文字里如果有必要会继续完善。

下一节主要的内容是介绍前后端交互的两个重要成员:request和response。express在nodejs的基础之上进行了丰富的扩展,所以很有必要仿制一下。

5. 第五次迭代

本节是expross的第五次迭代,主要的目标是封装request和response两个对象,方便使用。

其实nodejs已经给我们提供这两个默认的对象,之所以要封装是因为丰富一下二者的接口,方便框架使用者,目前框架在response对象上已经有一个接口:

if(!res.send) {
    res.send = function(body) {
        res.writeHead(200, {
            'Content-Type': 'text/plain'
        });
        res.end(body);
    };
}

如果需要继续封装,也要类似的结构在代码上添加显然会给人一种很乱的感觉,因为request和response的原始版本是nodejs提供给框架的,框架获取到的是两个对象,并不是类,要想在二者之上提供另一组接口的办法有很多,归根结底就是将新的接口加到该对象上或者加到该对象的原型链上,目前的代码选择了前者,express的代码选择了后者。

首先建立两个文件:request.js 和 response.js,二者分别导出req和res对象。

//request.js
var http = require('http');

var req = Object.create(http.IncomingMessage.prototype);

module.exports = req;


//response.js
var http = require('http');

var res = Object.create(http.ServerResponse.prototype);

module.exports = res;

二者文件的代码都是创建一个对象,分别指向nodejs提供的request和response两个对象的原型,以后expross自定的接口可以统一挂载到这两个对象上。

接着修改Application.handle函数,因为这个函数里面有新鲜出炉的request和response。思路很简单,就是将二者的原型指向我们自建的req和res。因为req和res对象的原型和request和response的原型相同,所以并不影响原有nodejs的接口。

var request = require('./request');
var response = require('./response');

...

Application.prototype.handle = function(req, res) {

    Object.setPrototypeOf(req, request);
    Object.setPrototypeOf(res, response);


    ...
};

这里将原有的res.send转移到了response.js文件中。

res.send = function(body) {
    this.writeHead(200, {
        'Content-Type': 'text/plain'
    });
    this.end(body);
};

注意函数中不在是res.writeHead和res.end,而是this.writeHead和this.end。

在整个路由系统中,Router.stack每一项其实都是一个中间件,每个中间件都有可能用到req和res这两个对象,所以代码中修改nodejs原生提供的request和response对象的代码放到了Application.handle中,这样做并没有问题,但是有一种更好的方式,expross框架将这部分代码封装成了一个内部中间件。

为了确保框架中每个中间件接收这两个参数的正确性,需要将该内部中间放到Router.stack的首项。这里将原有Application的构造函数中的代码去掉,不再是直接创建Router()路由系统,而是用一种惰性加载的方式,动态创建。

去除原有Application构造函数的代码。

function Application() {}

添加惰性初始化函数。

Application.prototype.lazyrouter = function() {
    if(!this._router) {
        this._router = new Router();

        this._router.use(middleware.init());
    }
};

因为是惰性初始化,所以在使用this._router对象前,一定要先调用该函数动态创建this._router对象。类似如下代码:

//获取router
this.lazyrouter();
router = this._router;

接下来创建一个叫middleware文件夹,专门放内部中间件的文件,再创建一个init.js文件,放置Application.handle中用来初始化res和req的代码。

var request = require('../request');
var response = require('../response');

exports.init = function expressInit(req, res, next) {
    //request文件可能用到res对象
    req.res = res;

    //response文件可能用到req对象
    res.req = req;

    //修改原始req和res原型
    Object.setPrototypeOf(req, request);
    Object.setPrototypeOf(res, response);

    //继续
    next();
};

修改原有的Applicaton.handle函数。

Application.prototype.handle = function(req, res) {

    ...

    // 这里无需调用lazyrouter,因为listen前一定调用了.use或者.METHODS方法。
    // 如果二者都没有调用,没有必要创建路由系统。this._router为undefined。
    var router = this._router;
    if(router) {
        router.handle(req, res, done);
    } else {
        done();
    }
};

运行node test/index.js走起……

express框架中,request和response两个对象有很多非常好用的函数,不过大部分和框架结构无关,并且这些函数内部过于专研细节,对框架本身的理解没有多少帮助。不过接下来有一个方面需要仔细研究一下,那就是前后端参数的传递,express如何获取并分类这些参数的,这一点还是需要略微了解。

默认情况下,一共有三种参数获取方式。

  • req.query 代表查询字符串。
  • req.params 代表路径变量。
  • req.body 代表表单提交变量。

req.query 是最常用的方式,例如:

// GET /search?q=tobi+ferret
req.query.q
// => "tobi ferret"

// GET /shoes?order=desc&shoe[color]=blue&shoe[type]=converse
req.query.order
// => "desc"

req.query.shoe.color
// => "blue"

req.query.shoe.type
// => "converse"

后台获取这些参数最简单的方式就是通过nodejs自带的querystring模块分析URL。express使用的是另一个npm包,qs。并且将其封装为另一个内部中间件,专门负责解析查询字符串,默认加载。

req.params 是另一种从URL获取参数的方式,例如:

//路由规则  /user/:name
// GET /user/tj
req.params.name
// => "tj"

这是一种express框架规定的参数获取方式,对于批量处理逻辑非常实用。在expross中并没有实现,因为路径匹配问题过于细节化,如果对此感兴趣可以研究研究path-to-regexp模块,express也是在其上的封装。

req.body 是获取表单数据的方式,但是默认情况下框架是不会去解析这种数据,直接使用只会返回undefined。如果想要支持需要添加其他中间件,例如 body-parsermulter

本小节主要介绍了request和response两个对象,并且讲解了一下现有expross框架中获取参数的方式,整体上并没有太深入的仿制,主要是这方面内容涉及的细节过多,只可意会。研究了也就仅仅知道而已,并不能带来多少积累,除非重头再造一次轮子。

6. 第六次迭代

本小节是第六次迭代,主要的目的是介绍一下expresss是如何集成现有的渲染引擎的。与渲染引擎有关的事情涉及到下面几个方面:

  • 如何开发或绑定一个渲染引擎。
  • 如何注册一个渲染引擎。
  • 如何指定模板路径。
  • 如何渲染模板引擎。

express通过app.engine(ext, callback) 方法即可创建一个你自己的模板引擎。其中,ext 指的是文件扩展名、callback 是模板引擎的主函数,接受文件路径、参数对象和回调函数作为其参数。

//下面的代码演示的是一个非常简单的能够渲染 “.ntl” 文件的模板引擎。

var fs = require('fs'); // 此模板引擎依赖 fs 模块
app.engine('ntl', function (filePath, options, callback) { // 定义模板引擎
  fs.readFile(filePath, function (err, content) {
    if (err) return callback(new Error(err));
    // 这是一个功能极其简单的模板引擎
    var rendered = content.toString().replace('#title#', '<title>'+ options.title +'</title>')
    .replace('#message#', '<h1>'+ options.message +'</h1>');
    return callback(null, rendered);
  })
});

为了让应用程序可以渲染模板文件,还需要做如下设置:

//views, 放模板文件的目录
app.set('views', './views')
//view engine, 模板引擎
app.set('view engine', 'jade')

一旦 view engine 设置成功,就不需要显式指定引擎,或者在应用中加载模板引擎模块,Express 已经在内部加载。下面是如何渲染页面的方法:

app.get('/', function (req, res) {
  res.render('index', { title: 'Hey', message: 'Hello there!'});
});

要想实现上述功能,首先在Application类中定义两个变量,一个存储app.set 和 app.get 这两个方法存储的值,另一个存储模板引擎中扩展名和渲染函数的对应关系。

function Application() {
    this.settings = {};
    this.engines = {};
}

然后是实现app.set函数。

Application.prototype.set = function(setting, val) {
      if (arguments.length === 1) {
      // app.get(setting)
      return this.settings[setting];
    }
  
    this.settings[setting] = val;
    return this;
};

代码中不仅仅实现了设置,如何传入的参数只有一个等价于get函数。

接着实现app.get函数。因为现在已经有了一个app.get方法用来设置路由,所以需要在该方法上进行重载。

http.METHODS.forEach(function(method) {
    method = method.toLowerCase();
    Application.prototype[method] = function(path, fn) {
        if(method === 'get' && arguments.length === 1) {
            // app.get(setting)
            return this.set(path);
        }

        ...
    };
});

最后实现app.engine进行扩展名和引擎函数的映射。

Application.prototype.engine = function(ext, fn) {
    // get file extension
    var extension = ext[0] !== '.'
      ? '.' + ext
      : ext;

    // store engine
    this.engines[extension] = fn;

    return this;
};

扩展名当做key,统一添加“.”。

到此设置模板引擎相关信息的函数算是完成,接下来就是最重要的渲染引擎函数的实现。

res.render = function(view, options, callback) {
      var app = this.req.app;
    var done = callback;
    var opts = options || {};
    var self = this;

    //如果定义回调,则返回,否则渲染
    done = done || function(err, str) {
        if(err) {
            return req.next(err);
        }

        self.send(str);
    };

    //渲染
    app.render(view, opts, done);
};

渲染函数一共有三个参数,view表示模板的名称,options是模板渲染的变量,callback是渲染成功后的回调函数。

函数内部直接调用render函数进行渲染,渲染完成后调用done回调。

接下来创建一个view.js文件,主要功能是负责各种模板引擎和框架间的隔离,保持对内接口的统一性。

function View(name, options) {
    var opts = options || {};

    this.defaultEngine = opts.defaultEngine;
    this.root = opts.root;

    this.ext = path.extname(name);
    this.name = name;


    var fileName = name;
    if (!this.ext) {
      // get extension from default engine name
      this.ext = this.defaultEngine[0] !== '.'
        ? '.' + this.defaultEngine
        : this.defaultEngine;

      fileName += this.ext;
    }


    // store loaded engine
    this.engine = opts.engines[this.ext];

    // lookup path
    this.path = this.lookup(fileName);
}

View类内部定义了很多属性,主要包括引擎、根目录、扩展名、文件名等等,为了以后的渲染做准备。

View.prototype.render = function render(options, callback) {
    this.engine(this.path, options, callback);
};

View的渲染函数内部就是调用一开始注册的引擎渲染函数。

了解了View的定义,接下来实现app.render模板渲染函数。

Application.prototype.render = function(name, options, callback) {
    var done = callback;
    var engines = this.engines;
    var opts = options;

    view = new View(name, {
      defaultEngine: this.get('view engine'),
      root: this.get('views'),
      engines: engines
    });


    if (!view.path) {
      var err = new Error('Failed to lookup view "' + name + '"');
      return done(err);
    }


    try {
      view.render(options, callback);
    } catch (e) {
      callback(e);
    }
};

还有一些细节没有在教程中展示出来,可以参考github上传的案例代码。

貌似一切搞定,修改测试用例,尝试一下。

var fs = require('fs'); // 此模板引擎依赖 fs 模块
app.engine('ntl', function (filePath, options, callback) { // 定义模板引擎
  fs.readFile(filePath, function (err, content) {
    if (err) return callback(new Error(err));
    // 这是一个功能极其简单的模板引擎
    var rendered = content.toString().replace('#title#', '<title>'+ options.title +'</title>')
    .replace('#message#', '<h1>'+ options.message +'</h1>');
    return callback(null, rendered);
  });
});

app.set('views', './test/views'); // 指定视图所在的位置
app.set('view engine', 'ntl'); // 注册模板引擎


app.get('/', function(req, res, next) {
    res.render('index', { title: 'Hey', message: 'Hello there!'});
});

运行node test/index.js,查看效果。

上面的代码是自己注册的引擎,如果想要和现有的模板引擎结合还需要在回调函数中引用模板自身的渲染方法,当然为了方便,express框架内部提供了一个默认方法,如果模板引擎导出了该方法,则表示该模板引擎支持express框架,无需使用app.engine再次封装。

该方法声明如下:

 __express(filePath, options, callback)

可以参考ejs模板引擎的代码,看看它们是如何写的:

//该行代码在lib/ejs.js文件的355行左右
exports.__express = exports.renderFile;

express框架是如何实现这个默认加载的功能的呢?很简单,只需要在View的构造函数中加一个判断即可。

if (!opts.engines[this.ext]) {
  // load engine
  var mod = this.ext.substr(1);
  opts.engines[this.ext] = require(mod).__express;
}

代码很简单,如果没有找到引擎对应的渲染函数,那就尝试加载__express函数。

后记

至此,算是结束本篇文章了。其实还有很多内容可以写,但是写的有些烦了,篇幅太长了,大概有一万三千多字,后面有点应付了事的感觉。

简单的说一下还有哪里没有介绍。

  • 关于Application。

如果稍微看过expross代码的人都会发现,Application并不是想我写的这样是一个类,而是一个中间件,一个对象,该对象使用了mixin方法的多继承手段,express.js文件中的createApplication函数算是整个框架的切入点。

  • 关于Router.handle。

这个函数可以说是整个express框架的核心,如果理解了该函数,整个框架基本上就掌握了。我在仿制的时候舍弃了很多细节,在这里个函数里面内部有两个个关键点没说。一、处理URL形式的参数,这里涉及对params参数的提取过程。其中有一个restore函数使用高阶函数的方法做了缓存,仔细体会很有意思。二、setImmediate异步返回,之所以要使用异步处理,是因为下面的代码需要运行,包括路径相关的参数,这些参数在下一个处理函数中可能会用到。

  • 关于其他函数。

太多函数了,不一一列举,前文已经提到,涉及的细节太多,正则表达式,http协议层,nodejs本身函数的使用,对于整个框架的理解帮助不大,全部舍弃。不过大多数函数都是自成体系,很好理解。

查看原文

赞 36 收藏 65 评论 23

布利丹牵驴子 赞了文章 · 2019-06-09

express源码阅读

express源码阅读

简介:这篇文章的主要目的是分析express的源码,但是网络上express的源码评析已经数不胜数,所以本文章另辟蹊径,准备仿制一个express的轮子,当然轮子的主体思路是阅读express源码所得。

源码地址:expross

1. 搭建结构

有了想法,下一步就是搭建一个山寨的框架,万事开头难,就从建立一个文件夹开始吧!

首先建立一个文件夹,叫做expross(你没有看错,山寨从名称开始)。

expross
 |
 |-- application.js

接着创建application.js文件,文件的内容就是官网的例子。

var http = require('http');

http.createServer(function(req, res) {
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end('Hello World');
}).listen(3000);

一个简单的http服务就创建完成了,你可以在命令行中启动它,而expross框架的搭建就从这个文件出发。

1.1 第一划 Application

在实际开发过程中,web后台框架的两个核心点就是路由和模板。路由说白了就是一组URL的管理,根据前端访问的URL执行对应的处理函数。怎样管理一组URL和其对应的执行函数呢?首先想到的就是数组(其实我想到的是对象)。

创建一个名称叫做router的数组对象。

var http = require('http');

//路由
var router = [];
router.push({path: '*', fn: function(req, res) {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    res.end('404');
}}, {path: '/', fn: function(req, res) {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    res.end('Hello World');
}});


http.createServer(function(req, res) {
    //自动匹配
    for(var i=1,len=router.length; i<len; i++) {
        if(req.url === router[i].path) {
            return router[i].fn(req, res);
        }
    }
    return router[0].fn(req, res);
}).listen(3000);

router数组用来管理所有的路由,数组的每个对象有两个属性组成,path表示路径,fn表示路径对应的执行函数。一切看起来都很不错,但是这并不是一个框架,为了组成一个框架,并且贴近express,这里继续对上面的代码进一步封装。

首先定义一个类:Application

var Application = function() {}

在这个类上定义二个函数:

Application.prototype.use = function(path, cb) {};

Application.prototype.listen = function(port) {};

把上面的实现,封装到这个类中。use 函数表示增加一个路由,listen 函数表示监听http服务器。

var http = require('http');

var Application = function() {
    this.router = [{
        path: '*', 
        fn: function(req, res) {
            res.writeHead(200, {'Content-Type': 'text/plain'});
            res.end('Cannot ' + req.method + ' ' + req.url);
        }
    }];
};

Application.prototype.use = function(path, cb) {
    this.router.push({
        path: path,
        fn: cb
    });
};

Application.prototype.listen = function(port) {
    var self = this;
    http.createServer(function(req, res) {
        for(var i=1,len=self.router.length; i<len; i++) {
            if(req.url === self.router[i].path) {
                return self.router[i].fn(req, res);
            }
        }
        return self.router[0].fn(req, res);
    }).listen(port);
};

可以像下面这样启动它:

var app = new Application();
app.use('/', function(req, res) {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    res.end('Hello World');
});
app.listen(3000);

看样子已经和express的外观很像了,为了更像,这里创建一个expross的文件,该文件用来实例化Application。代码如下:

var Application = require('./application');
exports = module.exports = createApplication;

function createApplication() {
    var app = new Application();
    return app;
}

为了更专业,调整目录结构如下:

-----expross
 | |
 | |-- index.js
 | |
 | |-- lib
 |      |
 |      |-- application.js
 |      |-- expross.js
 |
 |---- test.js

运行node test.js,走起……

1.2 第二划 Layer

为了进一步优化代码,这里抽象出一个概念:Layer。代表层的含义,每一层就是上面代码中的router数组的一个项。

Layer含有两个成员变量,分别是path和handle,path代表路由的路径,handle代表路由的处理函数fn。

------------------------------------------------
|     0     |     1     |     2     |     3     |      
------------------------------------------------
| Layer     | Layer     | Layer     | Layer     |
|  |- path  |  |- path  |  |- path  |  |- path  |
|  |- handle|  |- handle|  |- handle|  |- handle|
------------------------------------------------
                  router 内部

创建一个叫做layer的类,并为该类添加两个方法,handle_requestmatchmatch用来匹配请求路径是否符合该层,handle_request用来执行路径对应的处理函数。

function Layer(path, fn) {
    this.handle = fn;
    this.name = fn.name || '<anonymous>';
    this.path = path;
}
//简单处理
Layer.prototype.handle_request = function (req, res) {
  var fn = this.handle;

  if(fn) {
      fn(req, res);
  }
}
//简单匹配
Layer.prototype.match = function (path) {
    if(path === this.path) {
        return true;
    }
    
    return false;
}

因为router数组中存放的将是Layer对象,所以修改Application.prototype.use代码如下:

Application.prototype.use = function(path, cb) {
    this.router.push(new Layer(path, cb));
};

当然也不要忘记Application构造函数的修改。

var Application = function() {
    this.router = [new Layer('*', function(req, res) {
        res.writeHead(200, {'Content-Type': 'text/plain'});
        res.end('Cannot ' + req.method + ' ' + req.url);
    })];
};

接着改变listen函数,将其主要的处理逻辑抽取成handle函数,用来匹配处理请求信息。这样可以让函数本身的语意更明确,并且遵守单一原则。

Application.prototype.handle = function(req, res) {
    var self = this;

    for(var i=0,len=self.router.length; i<len; i++) {
        if(self.router[i].match(req.url)) {
            return self.router[i].handle_request(req, res);
        }
    }

    return self.router[0].handle_request(req, res);
};

listen函数变得简单明了。

Application.prototype.listen = function(port) {
    var self = this;

    http.createServer(function(req, res) {
        self.handle(req, res);
    }).listen(port);
};

运行node test.js,走起……

1.3 第三划 router

Application类中,成员变量router负责存储应用程序的所有路由和其处理函数,既然存在这样一个对象,为何不将其封装成一个Router类,这个类负责管理所有的路由,这样职责更加清晰,语意更利于理解。

so,这里抽象出另一个概念:Router,代表一个路由组件,包含若干层的信息。

建立Router类,并将原来Application内的代码移动到Router类中。

var Router = function() {
    this.stack = [new Layer('*', function(req, res) {
        res.writeHead(200, {'Content-Type': 'text/plain'});
        res.end('Cannot ' + req.method + ' ' + req.url);
    })];
};

Router.prototype.handle = function(req, res) {
    var self = this;

    for(var i=0,len=self.stack.length; i<len; i++) {
        if(self.stack[i].match(req.url)) {
            return self.stack[i].handle_request(req, res);
        }
    }

    return self.stack[0].handle_request(req, res);
};

Router.prototype.use = function(path, fn) {
    this.stack.push(new Layer(path, fn));
};

为了利于管理,现将路由相关的文件放到一个目录中,命名为router。将Router类文件命名为index.js保存到router文件夹内,并将原来的layer.js移动到该文件夹。现目录结构如下:

-----expross
 | |
 | |-- index.js
 | |
 | |-- lib
 |      |
 |      |-- router
 |      |     |
 |      |     |-- index.js
 |      |     |-- layer.js
 |      |     
 |      |
 |      |-- application.js
 |      |-- expross.js
 |
 |---- test.js

修改原有application.js文件,将代码原有router的数组移除,新增加_router对象,该对象是Router类的一个实例。

var Application = function() {
    this._router = new Router();
};

Application.prototype.use = function(path, fn) {
    var router = this._router;
    return router.use(path, fn);
};

Application.prototype.handle = function(req, res) {
    var router = this._router;
    router.handle(req, res);
};

到现在为止,整体的框架思路已经非常的明确,一个应用对象包括一个路由组件,一个路由组件包括n个层,每个层包含路径和处理函数。每次请求就遍历应用程序指向的路由组件,通过层的成员函数match来进行匹配识别URL访问的路径,如果成功则调用层的成员函数handle_request进行处理。

运行node test.js,走起……

1.4 第四划 route

如果研究过路由相关的知识就会发现,路由其实是由三个参数构成的:请求的URI、HTTP请求方法和路由处理函数。之前的代码只处理了其中两种,对于HTTP请求方法这个参数却刻意忽略,现在是时候把它加进来了。

按照上面的结构,如果加入请求方法参数,肯定会加入到Layer里面。但是再加入之前,需要仔细分析一下路由的常见方式:

GET        /pages
GET     /pages/1
POST    /page
PUT     /pages/1
DELETE     /pages/1

HTTP的请求方法有很多,上面的路由列表是一组常见的路由样式,遵循REST原则。分析一下会发现大部分的请求路径其实是相似或者是一致的,如果将每个路由都建立一个Layer添加到Router里面,从效率或者语意上都稍微有些不符,因为他们是一组URL,负责管理page相关信息的URL,能否把这样类似访问路径相同而请求方法不同的路由划分到一个组里面呢?

答案是可以行的,这就需要再次引入一个概念:route,专门来管理具体的路由信息。

------------------------------------------------
|     0     |     1     |     2     |     3     |      
------------------------------------------------
| item      | item      | item      | item      |
|  |- method|  |- method|  |- method|  |- method|
|  |- handle|  |- handle|  |- handle|  |- handle|
------------------------------------------------
                  route 内部

在写代码之前,先梳理一下上面所有的概念之间的关系:application、expross、router、route和layer。

 --------------
| Application  |                                 ---------------------------------------------------------
|     |           |        ----- -----------        |     0     |     1     |     2     |     3     |  ...  |
|      |-router | ----> |     | Layer     |       ---------------------------------------------------------
 --------------        |  0  |   |-path  |       | item      | item      | item      | item      |       |
  application           |     |   |-route | ----> |  |- method|  |- method|  |- method|  |- method|  ...  |
                       |-----|-----------|       |  |- handle|  |- handle|  |- handle|  |- handle|       |
                       |     | Layer     |       ---------------------------------------------------------
                       |  1  |   |-path  |                                  route
                       |     |   |-route |       
                       |-----|-----------|       
                       |     | Layer     |
                       |  2  |   |-path  |
                       |     |   |-route |
                       |-----|-----------|
                       | ... |   ...     |
                        ----- ----------- 
                             router

application代表一个应用程序。expross是一个工厂类负责创建application对象。router是一个路由组件,负责整个应用程序的路由系统。route是路由组件内部的一部分,负责存储真正的路由信息,内部的每一项都代表一个路由处理函数。router内部的每一项都是一个layer对象,layer内部保存一个route和其代表的URI。

如果一个请求来临,会现从头至尾的扫描router内部的每一层,而处理每层的时候会先对比URI,匹配扫描route的每一项,匹配成功则返回具体的信息,没有任何匹配则返回未找到。

创建Route类,定义三个成员变量和三个方法。path代表该route所对应的URI,stack代表上图中route内部item所在的数组,methods用来快速判断该route中是是否存在某种HTTP请求方法。

var Route = function(path) {
    this.path = path;
    this.stack = [];

    this.methods = {};
};

Route.prototype._handles_method = function(method) {
    var name = method.toLowerCase();
    return Boolean(this.methods[name]);
};

Route.prototype.get = function(fn) {
    var layer = new Layer('/', fn);
    layer.method = 'get';

    this.methods['get'] = true;
    this.stack.push(layer);

    return this;
};

Route.prototype.dispatch = function(req, res) {
    var self = this,
        method = req.method.toLowerCase();

    for(var i=0,len=self.stack.length; i<len; i++) {
        if(method === self.stack[i].method) {
            return self.stack[i].handle_request(req, res);
        }
    }
};

在上面的代码中,并没有定义前面结构图中的item对象,而是使用了Layer对象进行替代,主要是为了方便快捷,从另一种角度看,其实二者是存在很多共同点的。另外,为了利于理解,代码中只实现了GET方法,其他方法的代码实现是类似的。

既然有了Route类,接下来就改修改原有的Router类,将route集成其中。

Router.prototype.handle = function(req, res) {
    var self = this,
        method = req.method;

    for(var i=0,len=self.stack.length; i<len; i++) {
        if(self.stack[i].match(req.url) && 
            self.stack[i].route && self.stack[i].route._handles_method(method)) {
            return self.stack[i].handle_request(req, res);
        }
    }

    return self.stack[0].handle_request(req, res);
};

Router.prototype.get = function(path, fn) {
    var route = this.route(path);
    route.get(fn);
    return this;
};

Router.prototype.route = function route(path) {
    var route = new Route(path);

    var layer = new Layer(path, function(req, res) {
        route.dispatch(req, res)
    });

    layer.route = route;

    this.stack.push(layer);
    return route;
};

代码中,暂时去除use方法,创建get方法用来添加请求处理函数,route方法是为了返回一个新的Route对象,并将改层加入到router内部。

最后修改Application类中的函数,去除use方法,加入get方法进行测试。

Application.prototype.get = function(path, fn) {
    var router = this._router;
    return router.get(path, fn);
};

Application.prototype.route = function (path) {
  return this._router.route(path);
};

测试代码如下:

var expross = require('./expross');
var app = expross();

app.get('/', function(req, res) {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    res.end('Hello World');
});

app.route('/book')
.get(function(req, res) {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    res.end('Get a random book');
});

app.listen(3000);

运行node test.js,走起……

1.5 第五划 next

next 主要负责流程控制。在实际的代码中,有很多种情况都需要进行权限控制,例如:

var expross = require('./expross');
var app = expross();

app.get('/', function(req, res) {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    res.end('first');
});

app.get('/', function(req, res) {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    res.end('second');
});

app.listen(3000);

上面的代码如果执行会发现永远都返回first,但是有的时候会根据前台传来的参数动态判断是否执行接下来的路由,怎样才能跳过first进入secondexpress引入了next的概念。

跳转到任意layer,成本是比较高的,大多数的情况下并不需要。在express中,next跳转函数,有两种类型:

  • 跳转到下一个处理函数。执行 next()

  • 跳转到下一组route。执行 next('route')

要想使用next的功能,需要在代码书写的时候加入该参数:

var expross = require('./expross');
var app = expross();

app.get('/', function(req, res, next) {
    console.log('first');
    next();
});

app.get('/', function(req, res) {
    res.writeHead(200, {'Content-Type': 'text/plain'});
    res.end('second');
});

app.listen(3000);

而该功能的实现也非常简单,主要是在调用处理函数的时候,除了需要传入req、res之外,再传一个流程控制函数next。

Router.prototype.handle = function(req, res) {
    var self = this,
        method = req.method,
        i = 1, len = self.stack.length,
        stack;

    function next() {
        if(i >= len) {        
            return self.stack[0].handle_request(req, res);
        }

        stack = self.stack[i++];

        if(stack.match(req.url) && stack.route 
            && stack.route._handles_method(method)) {
            return stack.handle_request(req, res, next);
        } else {
            next();
        }
    }

    next();
};

修改原有Router的handle函数。因为要控制流程,所以for循环并不是很合适,可以更换为while循环,或者干脆使用类似递归的手法。

代码中定义一个next函数,然后执行next函数进行自启动。next内部和之前的操作类似,主要是执行handle_request函数进行处理,不同之处是调用该函数的时候,将next本身当做参数传入,这样可以在内部执行该函数进行下一个处理,类似给handle_request赋予for循环中++的能力。

按照相同的方式,修改Route的dispatch函数。

Route.prototype.dispatch = function(req, res, done) {
    var self = this,
        method = req.method.toLowerCase(),
        i = 0, len = self.stack.length, stack;

    function next(gt) {
        if(gt === 'route') {
            return done();
        }

        if(i >= len) {
            return done();
        }

        stack = self.stack[i++];

        if(method === stack.method) {
            return stack.handle_request(req, res, next);
        } else {
            next();
        }        
    }

    next();
};

代码思路基本和上面的相同,唯一的差别就是增加route判断,提供跳过当前整组处理函数的能力。

Layer.prototype.handle_request = function (req, res, next) {
  var fn = this.handle;

  if(fn) {
      fn(req, res, next);
  }
}

Router.prototype.route = function route(path) {
    var route = new Route(path);

    var layer = new Layer(path, function(req, res, next) {
        route.dispatch(req, res, next)
    });

    layer.route = route;

    this.stack.push(layer);
    return route;
};

最后不要忘记修改Layer的handle_request函数和Router的route函数。

1.6 后记

该小结基本结束,当然如果要继续还可以写很多内容,包括错误处理、函数重载、高阶函数(生成各种HTTP函数),以及各种神奇的用法,如继承、缓存、复用等等。

但是我觉得搭建结构这一结已经将express的基本结构捋清了,如果重头到尾的走下来,再去读框架的源码应该是没有问题的。

接下来继续山寨express 的其他部分。

查看原文

赞 5 收藏 12 评论 1

布利丹牵驴子 评论了文章 · 2019-05-13

如何设计redux state结构

为什么使用redux

使用react构建大型应用,势必会面临状态管理的问题,redux是常用的一种状态管理库,我们会因为各种原因而需要使用它。

  1. 不同的组件可能会使用相同的数据,使用redux能更好的复用数据和保持数据的同步
  2. react中子组件访问父组件的数据只能通过props层层传递,使用redux可以轻松的访问到想要的数据
  3. 全局的state可以很容易的进行数据持久化,方便下次启动app时获得初始state
  4. dev tools提供状态快照回溯的功能,方便问题的排查

但并不是所有的state都要交给redux管理,当某个状态数据只被一个组件依赖或影响,且在切换路由再次返回到当前页面不需要保留操作状态时,我们是没有必要使用redux的,用组件内部state足以。例如下拉框的显示与关闭。

常见的状态类型

react应用中我们会定义很多state,state最终也都是为页面展示服务的,根据数据的来源、影响的范围大致可以将前端state归为以下三类:

Domain data: 一般可以理解为从服务器端获取的数据,比如帖子列表数据、评论数据等。它们可能被应用的多个地方用到,前端需要关注的是与后端的数据同步、提交等等。

UI state: 决定当前UI如何展示的状态,比如一个弹窗的开闭,下拉菜单是否打开,往往聚焦于某个组件内部,状态之间可以相互独立,也可能多个状态共同决定一个UI展示,这也是UI state管理的难点。

App state: App级的状态,例如当前是否有请求正在loading、某个联系人被选中、当前的路由信息等可能被多个组件共同使用到状态。

如何设计state结构

在使用redux的过程中,我们都会使用modules的方式,将我们的reducers拆分到不同的文件当中,通常会遵循高内聚、方便使用的原则,按某个功能模块、页面来划分。那对于某个reducer文件,如何设计state结构能更方便我们管理数据呢,下面列出几种常见的方式:

1.将api返回的数据直接放入state

这种方式大多会出现在列表的展示上,如帖子列表页,因为后台接口返回的数据通常与列表的展示结构基本一致,可以直接使用。

2.以页面UI来设计state结构

如下面的页面,分为三个section,对应开户中、即将流失、已提交审核三种不同的数据类型。
示例
因为页面是展示性的没有太多的交互,所以我们完全可以根据页面UI来设计如下的结构:

tabData: {
    opening: [{
        userId: "6332",
        mobile: "1858849****",
        name: "test1",
        ...
    }, ...],
    missing: [],
    commit: [{
        userId: "6333",
        mobile: "1858849****",
        name: "test2",
        ...
    }, ... ]
}

这样设计比较方便我们将state映射到页面,拉取更多数据只需要将新数据简单contact进对应的数组即可。对于简单页面,这样是可行的。

3.State范式化(normalize)

很多情况下,处理的数据都是嵌套或互相关联的。例如,一个群列表,由很多群组成,每个群又包含很多个用户,一个用户可以加入多个不同的群。这种类型的数据,我们可以方便用如下结构表示:

const Groups = [
    {
        id: 'group1',
        groupName: '连线电商',
        groupMembers: [
            {
                id: 'user1',
                name: '张三',
                dept: '电商部'
            },
            {
                id: 'user2',
                name: '李四',
                dept: '电商部'
            },
        ]
    },
    {
        id: 'group2',
        groupName: '连线资管',
        groupMembers: [
            {
                id: 'user1',
                name: '张三',
                dept: '电商部'
            },
            {
                id: 'user3',
                name: '王五',
                dept: '电商部'
            },
        ]
    }
]

这种方式,对界面展示很友好,展示群列表,我们只需遍历Groups数组,展示某个群成员列表,只需遍历相应索引的数据Groups[index],展示某个群成员的数据,继续索引到对应的成员数据GroupsgroupIndex即可。
但是这种方式有一些问题:

  1. 存在很多重复数据,当某个群成员信息更新的时候,想要在不同的群之间进行同步比较麻烦。
  2. 嵌套过深,导致reducer逻辑复杂,修改深层的属性会导致代码臃肿,空指针的问题
  3. redux中需要遵循不可变更新模式,更新属性往往需要更新组件树的祖先,产生新的引用,这会导致跟修改数据无关的组件也要重新render。

为了避免上面的问题,我们可以借鉴数据库存储数据的方式,设计出类似的范式化的state,范式化的数据遵循下面几个原则:

  • 不同类型的数据,都以“数据表”的形式存储在state中
  • “数据表” 中的每一项条目都以对象的形式存储,对象以唯一性的ID作为key,条目本身作为value。
  • 任何对单个条目的引用都应该根据存储条目的 ID 来索引完成。
  • 数据的顺序通过ID数组表示。

上面的示例范式化之后如下:

{
    groups: {
        byIds: {
            group1: {
                id: 'group1',
                groupName: '连线电商',
                groupMembers: ['user1', 'user2']
            },
            group2: {
                id: 'group2',
                groupName: '连线资管',
                groupMembers: ['user1', 'user3']
            }
        },
        allIds: ['group1', 'group2']
    },
    members: {
        byIds: {
            user1: {
                id: 'user1',
                name: '张三',
                dept: '电商部'
            },
            user2: {
                id: 'user2',
                name: '李四',
                dept: '电商部'
            },
            user3: {
                id: 'user3',
                name: '王五',
                dept: '电商部'
            }
        },
        allIds: []
    }
}

与原来的数据相比有如下改进:

  1. 因为数据是扁平的,且只被定义在一个地方,更方便数据更新
  2. 检索或者更新给定数据项的逻辑变得简单与一致。给定一个数据项的 type 和 ID,不必嵌套引用其他对象而是通过几个简单的步骤就能查找到它。
  3. 每个数据类型都是唯一的,像用户信息这样的更新仅仅需要状态树中 “members > byId > user” 这部分的复制。这也就意味着在 UI 中只有数据发生变化的一部分才会发生更新。与之前的不同的是,之前嵌套形式的结构需要更新整个 groupMembers数组,以及整个 groups数组。这样就会让不必要的组件也再次重新渲染。

通常我们接口返回的数据都是嵌套形式的,要将数据范式化,我们可以使用Normalizr这个库来辅助。
当然这样做之前我们最好问自己,我是否需要频繁的遍历数据,是否需要快速的访问某一项数据,是否需要频繁更新同步数据。

更进一步

对于这些关系数据,我们可以统一放到entities中进行管理,这样root state,看起来像这样:

{
    simpleDomainData1: {....},
    simpleDomainData2: {....}
    entities : {
        entityType1 : {byId: {}, allIds},
        entityType2 : {....}
    }
    ui : {
        uiSection1 : {....},
        uiSection2 : {....}
    }
}

其实上面的entities并不够纯粹,因为其中包含了关联关系(group里面包含了groupMembers的信息),也包含了列表的顺序信息(如每个实体的allIds属性)。更进一步,我们可以将这些信息剥离出来,让我们的entities更加简单,扁平。

{
    entities: {
        groups: {
            group1: {
                id: 'group1',
                groupName: '连线电商',
            },
            group2: {
                id: 'group2',
                groupName: '连线资管',
            }
        },
        members: {
            user1: {
                id: 'user1',
                name: '张三',
                dept: '电商部'
            },
            user2: {
                id: 'user2',
                name: '李四',
                dept: '电商部'
            },
            user3: {
                id: 'user3',
                name: '王五',
                dept: '电商部'
            }
        }
    },
    
    groups: {
        gourpIds: ['group1', 'group2'],
        groupMembers: {
            group1: ['user1', 'user2'],
            group2: ['user2', 'user3']
        }
    }
}

这样我们在更新entity信息的时候,只需操作对应entity就可以了,添加新的entity时则需要在对应的对象如entities[group]中添加group对象,在groups[groupIds]中添加对应的关联关系。

enetities.js

const ADD_GROUP = 'entities/addGroup';
const UPDATE_GROUP = 'entities/updateGroup';
const ADD_MEMBER = 'entites/addMember';
const UPDATE_MEMBER = 'entites/updateMember';

export const addGroup = entity => ({
    type: ADD_GROUP,
    payload: {[entity.id]: entity}
})

export const updateGroup = entity => ({
  type: UPDATE_GROUP,
  payload: {[entity.id]: entity}
})

export const addMember = member => ({
  type: ADD_MEMBER,
  payload: {[member.id]: member}
})

export const updateMember = member => ({
  type: UPDATE_MEMBER,
  payload: {[member.id]: member}
})

_addGroup(state, action) {
  return state.set('groups', state.groups.merge(action.payload));
}

_addMember(state, action) {
  return state.set('members', state.members.merge(action.payload));
}

_updateGroup(state, action) {
  return state.set('groups', state.groups.merge(action.payload, {deep: true}));
}

_updateMember(state, action) {
  return state.set('members', state.members.merge(action.payload, {deep: true}))
}

const initialState = Immutable({
  groups: {},
  members: {}
})

export default function entities(state = initialState, action) {
  let type = action.type;

  switch (type) {
    case ADD_GROUP:
      return _addGroup(state, action);
    case UPDATE_GROUP:
      return _updateGroup(state, action);
    case ADD_MEMBER:
      return _addMember(state, action);
    case UPDATE_MEMBER:
      return _updateMember(state, action);
    default:
      return state;
  }
}

可以看到,因为entity的结构大致相同,所以更新起来很多逻辑是差不多的,所以这里可以进一步提取公用函数,在payload里面加入要更新的key值。

export const addGroup = entity => ({
  type: ADD_GROUP,
  payload: {data: {[entity.id]: entity}, key: 'groups'}
})

export const updateGroup = entity => ({
  type: UPDATE_GROUP,
  payload: {data: {[entity.id]: entity}, key: 'groups'}
})

export const addMember = member => ({
  type: ADD_MEMBER,
  payload: {data: {[member.id]: member}, key: 'members'}
})

export const updateMember = member => ({
  type: UPDATE_MEMBER,
  payload: {data: {[member.id]: member}, key: 'members'}
})

function normalAddReducer(state, action) {
  let payload = action.payload;
  if (payload && payload.key) {
    let {key, data} = payload;
    return state.set(key, state[key].merge(data));
  }
  return state;
}

function normalUpdateReducer(state, action) {
  if (payload && payload.key) {
    let {key, data} = payload;
    return state.set(key, state[key].merge(data, {deep: true}));
  }
}

export default function entities(state = initialState, action) {
  let type = action.type;

  switch (type) {
    case ADD_GROUP:
    case ADD_MEMBER:
      return normalAddReducer(state, action);
    case UPDATE_GROUP:    
    case UPDATE_MEMBER:
      return normalUpdateReducer(state, action);
    default:
      return state;
  }
}

将loading状态抽离到根reducer中,统一管理

在请求接口时,通常会dispatch loading状态,通常我们会在某个接口请求的reducer里面来处理响应的loading状态,这会使loading逻辑到处都是。其实我们可以将loading状态作为根reducer的一部分,单独管理,这样就可以复用响应的逻辑。

const SET_LOADING = 'SET_LOADING';

export const LOADINGMAP = {
  groupsLoading: 'groupsLoading',
  memberLoading: 'memberLoading'
}

const initialLoadingState = Immutable({
  [LOADINGMAP.groupsLoading]: false,
  [LOADINGMAP.memberLoading]: false,
});

const loadingReducer = (state = initialLoadingState, action) => {
  const { type, payload } = action;
  if (type === SET_LOADING) {
    return state.set(key, payload.loading);
  } else {
    return state;
  }
}

const setLoading = (scope, loading) => {
  return {
    type: SET_LOADING,
    payload: {
      key: scope,
      loading,
    },
  };
}

// 使用的时候
store.dispatch(setLoading(LOADINGMAP.groupsLoading, true));

这样当需要添加新的loading状态的时候,只需要在LOADINGMAP和initialLoadingState添加相应的loading type即可。
也可以参考dva的实现方式,它也是将loading存储在根reducer,并且是根据model的namespace作为区分,

dva loading

它方便的地方在于将更新loading状态的逻辑被提取到plugin中,用户不需要手动编写更新loading的逻辑,只需要在用到时候使用state即可。plugin的代码也很简单,就是在钩子函数中拦截副作用。

function onEffect(effect, { put }, model, actionType) {
    const { namespace } = model;

  return function*(...args) {
    yield put({ type: SHOW, payload: { namespace, actionType } });
    yield effect(...args);
    yield put({ type: HIDE, payload: { namespace, actionType } });
  };
}

其他

对于web端应用,我们无法控制用户的操作路径,很可能用户在直接访问某个页面的时候,我们store中并没有准备好数据,这可能会导致一些问题,所以有人建议以page为单位划分store,舍弃掉部分多页面共享state的好处,具体可以参考这篇文章,其中提到在视图之间共享state要谨慎,其实这也反映出我们在思考是否要共享某个state时,思考如下几个问题:

  1. 有多少页面会使用到该数据
  2. 每个页面是否需要单独的数据副本
  3. 改动数据的频率怎么样

参考文章

https://www.zhihu.com/questio...
https://segmentfault.com/a/11...
https://hackernoon.com/shape-...
https://medium.com/@dan_abram...
https://medium.com/@fastphras...
https://juejin.im/post/59a16e...
http://cn.redux.js.org/docs/r...
https://redux.js.org/recipes/...

查看原文

布利丹牵驴子 发布了文章 · 2019-05-12

如何设计redux state结构

为什么使用redux

使用react构建大型应用,势必会面临状态管理的问题,redux是常用的一种状态管理库,我们会因为各种原因而需要使用它。

  1. 不同的组件可能会使用相同的数据,使用redux能更好的复用数据和保持数据的同步
  2. react中子组件访问父组件的数据只能通过props层层传递,使用redux可以轻松的访问到想要的数据
  3. 全局的state可以很容易的进行数据持久化,方便下次启动app时获得初始state
  4. dev tools提供状态快照回溯的功能,方便问题的排查

但并不是所有的state都要交给redux管理,当某个状态数据只被一个组件依赖或影响,且在切换路由再次返回到当前页面不需要保留操作状态时,我们是没有必要使用redux的,用组件内部state足以。例如下拉框的显示与关闭。

常见的状态类型

react应用中我们会定义很多state,state最终也都是为页面展示服务的,根据数据的来源、影响的范围大致可以将前端state归为以下三类:

Domain data: 一般可以理解为从服务器端获取的数据,比如帖子列表数据、评论数据等。它们可能被应用的多个地方用到,前端需要关注的是与后端的数据同步、提交等等。

UI state: 决定当前UI如何展示的状态,比如一个弹窗的开闭,下拉菜单是否打开,往往聚焦于某个组件内部,状态之间可以相互独立,也可能多个状态共同决定一个UI展示,这也是UI state管理的难点。

App state: App级的状态,例如当前是否有请求正在loading、某个联系人被选中、当前的路由信息等可能被多个组件共同使用到状态。

如何设计state结构

在使用redux的过程中,我们都会使用modules的方式,将我们的reducers拆分到不同的文件当中,通常会遵循高内聚、方便使用的原则,按某个功能模块、页面来划分。那对于某个reducer文件,如何设计state结构能更方便我们管理数据呢,下面列出几种常见的方式:

1.将api返回的数据直接放入state

这种方式大多会出现在列表的展示上,如帖子列表页,因为后台接口返回的数据通常与列表的展示结构基本一致,可以直接使用。

2.以页面UI来设计state结构

如下面的页面,分为三个section,对应开户中、即将流失、已提交审核三种不同的数据类型。
示例
因为页面是展示性的没有太多的交互,所以我们完全可以根据页面UI来设计如下的结构:

tabData: {
    opening: [{
        userId: "6332",
        mobile: "1858849****",
        name: "test1",
        ...
    }, ...],
    missing: [],
    commit: [{
        userId: "6333",
        mobile: "1858849****",
        name: "test2",
        ...
    }, ... ]
}

这样设计比较方便我们将state映射到页面,拉取更多数据只需要将新数据简单contact进对应的数组即可。对于简单页面,这样是可行的。

3.State范式化(normalize)

很多情况下,处理的数据都是嵌套或互相关联的。例如,一个群列表,由很多群组成,每个群又包含很多个用户,一个用户可以加入多个不同的群。这种类型的数据,我们可以方便用如下结构表示:

const Groups = [
    {
        id: 'group1',
        groupName: '连线电商',
        groupMembers: [
            {
                id: 'user1',
                name: '张三',
                dept: '电商部'
            },
            {
                id: 'user2',
                name: '李四',
                dept: '电商部'
            },
        ]
    },
    {
        id: 'group2',
        groupName: '连线资管',
        groupMembers: [
            {
                id: 'user1',
                name: '张三',
                dept: '电商部'
            },
            {
                id: 'user3',
                name: '王五',
                dept: '电商部'
            },
        ]
    }
]

这种方式,对界面展示很友好,展示群列表,我们只需遍历Groups数组,展示某个群成员列表,只需遍历相应索引的数据Groups[index],展示某个群成员的数据,继续索引到对应的成员数据GroupsgroupIndex即可。
但是这种方式有一些问题:

  1. 存在很多重复数据,当某个群成员信息更新的时候,想要在不同的群之间进行同步比较麻烦。
  2. 嵌套过深,导致reducer逻辑复杂,修改深层的属性会导致代码臃肿,空指针的问题
  3. redux中需要遵循不可变更新模式,更新属性往往需要更新组件树的祖先,产生新的引用,这会导致跟修改数据无关的组件也要重新render。

为了避免上面的问题,我们可以借鉴数据库存储数据的方式,设计出类似的范式化的state,范式化的数据遵循下面几个原则:

  • 不同类型的数据,都以“数据表”的形式存储在state中
  • “数据表” 中的每一项条目都以对象的形式存储,对象以唯一性的ID作为key,条目本身作为value。
  • 任何对单个条目的引用都应该根据存储条目的 ID 来索引完成。
  • 数据的顺序通过ID数组表示。

上面的示例范式化之后如下:

{
    groups: {
        byIds: {
            group1: {
                id: 'group1',
                groupName: '连线电商',
                groupMembers: ['user1', 'user2']
            },
            group2: {
                id: 'group2',
                groupName: '连线资管',
                groupMembers: ['user1', 'user3']
            }
        },
        allIds: ['group1', 'group2']
    },
    members: {
        byIds: {
            user1: {
                id: 'user1',
                name: '张三',
                dept: '电商部'
            },
            user2: {
                id: 'user2',
                name: '李四',
                dept: '电商部'
            },
            user3: {
                id: 'user3',
                name: '王五',
                dept: '电商部'
            }
        },
        allIds: []
    }
}

与原来的数据相比有如下改进:

  1. 因为数据是扁平的,且只被定义在一个地方,更方便数据更新
  2. 检索或者更新给定数据项的逻辑变得简单与一致。给定一个数据项的 type 和 ID,不必嵌套引用其他对象而是通过几个简单的步骤就能查找到它。
  3. 每个数据类型都是唯一的,像用户信息这样的更新仅仅需要状态树中 “members > byId > user” 这部分的复制。这也就意味着在 UI 中只有数据发生变化的一部分才会发生更新。与之前的不同的是,之前嵌套形式的结构需要更新整个 groupMembers数组,以及整个 groups数组。这样就会让不必要的组件也再次重新渲染。

通常我们接口返回的数据都是嵌套形式的,要将数据范式化,我们可以使用Normalizr这个库来辅助。
当然这样做之前我们最好问自己,我是否需要频繁的遍历数据,是否需要快速的访问某一项数据,是否需要频繁更新同步数据。

更进一步

对于这些关系数据,我们可以统一放到entities中进行管理,这样root state,看起来像这样:

{
    simpleDomainData1: {....},
    simpleDomainData2: {....}
    entities : {
        entityType1 : {byId: {}, allIds},
        entityType2 : {....}
    }
    ui : {
        uiSection1 : {....},
        uiSection2 : {....}
    }
}

其实上面的entities并不够纯粹,因为其中包含了关联关系(group里面包含了groupMembers的信息),也包含了列表的顺序信息(如每个实体的allIds属性)。更进一步,我们可以将这些信息剥离出来,让我们的entities更加简单,扁平。

{
    entities: {
        groups: {
            group1: {
                id: 'group1',
                groupName: '连线电商',
            },
            group2: {
                id: 'group2',
                groupName: '连线资管',
            }
        },
        members: {
            user1: {
                id: 'user1',
                name: '张三',
                dept: '电商部'
            },
            user2: {
                id: 'user2',
                name: '李四',
                dept: '电商部'
            },
            user3: {
                id: 'user3',
                name: '王五',
                dept: '电商部'
            }
        }
    },
    
    groups: {
        gourpIds: ['group1', 'group2'],
        groupMembers: {
            group1: ['user1', 'user2'],
            group2: ['user2', 'user3']
        }
    }
}

这样我们在更新entity信息的时候,只需操作对应entity就可以了,添加新的entity时则需要在对应的对象如entities[group]中添加group对象,在groups[groupIds]中添加对应的关联关系。

enetities.js

const ADD_GROUP = 'entities/addGroup';
const UPDATE_GROUP = 'entities/updateGroup';
const ADD_MEMBER = 'entites/addMember';
const UPDATE_MEMBER = 'entites/updateMember';

export const addGroup = entity => ({
    type: ADD_GROUP,
    payload: {[entity.id]: entity}
})

export const updateGroup = entity => ({
  type: UPDATE_GROUP,
  payload: {[entity.id]: entity}
})

export const addMember = member => ({
  type: ADD_MEMBER,
  payload: {[member.id]: member}
})

export const updateMember = member => ({
  type: UPDATE_MEMBER,
  payload: {[member.id]: member}
})

_addGroup(state, action) {
  return state.set('groups', state.groups.merge(action.payload));
}

_addMember(state, action) {
  return state.set('members', state.members.merge(action.payload));
}

_updateGroup(state, action) {
  return state.set('groups', state.groups.merge(action.payload, {deep: true}));
}

_updateMember(state, action) {
  return state.set('members', state.members.merge(action.payload, {deep: true}))
}

const initialState = Immutable({
  groups: {},
  members: {}
})

export default function entities(state = initialState, action) {
  let type = action.type;

  switch (type) {
    case ADD_GROUP:
      return _addGroup(state, action);
    case UPDATE_GROUP:
      return _updateGroup(state, action);
    case ADD_MEMBER:
      return _addMember(state, action);
    case UPDATE_MEMBER:
      return _updateMember(state, action);
    default:
      return state;
  }
}

可以看到,因为entity的结构大致相同,所以更新起来很多逻辑是差不多的,所以这里可以进一步提取公用函数,在payload里面加入要更新的key值。

export const addGroup = entity => ({
  type: ADD_GROUP,
  payload: {data: {[entity.id]: entity}, key: 'groups'}
})

export const updateGroup = entity => ({
  type: UPDATE_GROUP,
  payload: {data: {[entity.id]: entity}, key: 'groups'}
})

export const addMember = member => ({
  type: ADD_MEMBER,
  payload: {data: {[member.id]: member}, key: 'members'}
})

export const updateMember = member => ({
  type: UPDATE_MEMBER,
  payload: {data: {[member.id]: member}, key: 'members'}
})

function normalAddReducer(state, action) {
  let payload = action.payload;
  if (payload && payload.key) {
    let {key, data} = payload;
    return state.set(key, state[key].merge(data));
  }
  return state;
}

function normalUpdateReducer(state, action) {
  if (payload && payload.key) {
    let {key, data} = payload;
    return state.set(key, state[key].merge(data, {deep: true}));
  }
}

export default function entities(state = initialState, action) {
  let type = action.type;

  switch (type) {
    case ADD_GROUP:
    case ADD_MEMBER:
      return normalAddReducer(state, action);
    case UPDATE_GROUP:    
    case UPDATE_MEMBER:
      return normalUpdateReducer(state, action);
    default:
      return state;
  }
}

将loading状态抽离到根reducer中,统一管理

在请求接口时,通常会dispatch loading状态,通常我们会在某个接口请求的reducer里面来处理响应的loading状态,这会使loading逻辑到处都是。其实我们可以将loading状态作为根reducer的一部分,单独管理,这样就可以复用响应的逻辑。

const SET_LOADING = 'SET_LOADING';

export const LOADINGMAP = {
  groupsLoading: 'groupsLoading',
  memberLoading: 'memberLoading'
}

const initialLoadingState = Immutable({
  [LOADINGMAP.groupsLoading]: false,
  [LOADINGMAP.memberLoading]: false,
});

const loadingReducer = (state = initialLoadingState, action) => {
  const { type, payload } = action;
  if (type === SET_LOADING) {
    return state.set(key, payload.loading);
  } else {
    return state;
  }
}

const setLoading = (scope, loading) => {
  return {
    type: SET_LOADING,
    payload: {
      key: scope,
      loading,
    },
  };
}

// 使用的时候
store.dispatch(setLoading(LOADINGMAP.groupsLoading, true));

这样当需要添加新的loading状态的时候,只需要在LOADINGMAP和initialLoadingState添加相应的loading type即可。
也可以参考dva的实现方式,它也是将loading存储在根reducer,并且是根据model的namespace作为区分,

dva loading

它方便的地方在于将更新loading状态的逻辑被提取到plugin中,用户不需要手动编写更新loading的逻辑,只需要在用到时候使用state即可。plugin的代码也很简单,就是在钩子函数中拦截副作用。

function onEffect(effect, { put }, model, actionType) {
    const { namespace } = model;

  return function*(...args) {
    yield put({ type: SHOW, payload: { namespace, actionType } });
    yield effect(...args);
    yield put({ type: HIDE, payload: { namespace, actionType } });
  };
}

其他

对于web端应用,我们无法控制用户的操作路径,很可能用户在直接访问某个页面的时候,我们store中并没有准备好数据,这可能会导致一些问题,所以有人建议以page为单位划分store,舍弃掉部分多页面共享state的好处,具体可以参考这篇文章,其中提到在视图之间共享state要谨慎,其实这也反映出我们在思考是否要共享某个state时,思考如下几个问题:

  1. 有多少页面会使用到该数据
  2. 每个页面是否需要单独的数据副本
  3. 改动数据的频率怎么样

参考文章

https://www.zhihu.com/questio...
https://segmentfault.com/a/11...
https://hackernoon.com/shape-...
https://medium.com/@dan_abram...
https://medium.com/@fastphras...
https://juejin.im/post/59a16e...
http://cn.redux.js.org/docs/r...
https://redux.js.org/recipes/...

查看原文

赞 15 收藏 10 评论 3

布利丹牵驴子 评论了文章 · 2019-03-26

谈一谈创建React Component的几种方式

当我们谈起React的时候,多半会将注意力集中在组件之上,思考如何将页面划分成一个个组件,以及如何编写可复用的组件。但对于接触React不久,还没有真正用它做一个完整项目的人来说,理解如何创建一个组件也并不那么简单。在最开始的时候我以为创建组件只需要调用createClass这个api就可以了;但学习了ES6的语法后,又知道了可以利用继承,通过extends React.component来创建组件;后来在阅读别人代码的时候又发现了PureComponent以及完全没有继承,仅仅通过返回JSX语句的方式创建组件的方式。下面这篇文章,就将逐一介绍这几种创建组件的方法,分析其特点,以及如何选择使用哪一种方式创建组件。

几种方法

1.createClass

如果你还没有使用ES6语法,那么定义组件,只能使用React.createClass这个helper来创建组件,下面是一段示例:

var React = require("react");
var Greeting = React.createClass({
  
  propTypes: {
    name: React.PropTypes.string //属性校验
  },

  getDefaultProps: function() {
    return {
      name: 'Mary' //默认属性值
    };
  },
  
  getInitialState: function() {
    return {count: this.props.initialCount}; //初始化state
  },
  
  handleClick: function() {
    //用户点击事件的处理函数
  },

  render: function() {
    return <h1>Hello, {this.props.name}</h1>;
  }
});
module.exports = Greeting;

这段代码,包含了组件的几个关键组成部分,这种方式下,组件的props、state等都是以对象属性的方式组合在一起,其中默认属props和初始state都是返回对象的函数,propTypes则是个对象。这里还有一个值得注意的事情是,在createClass中,React对属性中的所有函数都进行了this绑定,也就是如上面的hanleClick其实相当于handleClick.bind(this)

2.component

因为ES6对类和继承有语法级别的支持,所以用ES6创建组件的方式更加优雅,下面是示例:

import React from 'react';
class Greeting extends React.Component {

  constructor(props) {
    super(props);
    this.state = {count: props.initialCount};
    this.handleClick = this.handleClick.bind(this);
  }
  
  //static defaultProps = {
  //  name: 'Mary'  //定义defaultprops的另一种方式
  //}
  
  //static propTypes = {
    //name: React.PropTypes.string
  //}
  
  handleClick() {
    //点击事件的处理函数
  }
  
  render() {
    return <h1>Hello, {this.props.name}</h1>;
  }
}

Greeting.propTypes = {
  name: React.PropTypes.string
};

Greeting.defaultProps = {
  name: 'Mary'
};
export default Greating;

可以看到Greeting继承自React.component,在构造函数中,通过super()来调用父类的构造函数,同时我们看到组件的state是通过在构造函数中对this.state进行赋值实现,而组件的props是在类Greeting上创建的属性,如果你对类的属性对象的属性的区别有所了解的话,大概能理解为什么会这么做。对于组件来说,组件的props是父组件通过调用子组件向子组件传递的,子组件内部不应该对props进行修改,它更像是所有子组件实例共享的状态,不会因为子组件内部操作而改变,因此将props定义为类Greeting的属性更为合理,而在面向对象的语法中类的属性通常被称作静态(static)属性,这也是为什么props还可以像上面注释掉的方式来定义。对于Greeting类的一个实例对象的state,它是组件对象内部维持的状态,通过用户操作会修改这些状态,每个实例的state也可能不同,彼此间不互相影响,因此通过this.state来设置。

用这种方式创建组件时,React并没有对内部的函数,进行this绑定,所以如果你想让函数在回调中保持正确的this,就要手动对需要的函数进行this绑定,如上面的handleClick,在构造函数中对this 进行了绑定。

3.PureComponet

我们知道,当组件的props或者state发生变化的时候:React会对组件当前的Props和State分别与nextProps和nextState进行比较,当发现变化时,就会对当前组件以及子组件进行重新渲染,否则就不渲染。有时候为了避免组件进行不必要的重新渲染,我们通过定义shouldComponentUpdate来优化性能。例如如下代码:

class CounterButton extends React.Component {
  constructor(props) {
    super(props);
    this.state = {count: 1};
  }

  shouldComponentUpdate(nextProps, nextState) {
    if (this.props.color !== nextProps.color) {
      return true;
    }
    if (this.state.count !== nextState.count) {
      return true;
    }
    return false;
  }

  render() {
    return (
      <button
        color={this.props.color}
        onClick={() => this.setState(state => ({count: state.count + 1}))}>
        Count: {this.state.count}
      </button>
    );
  }
}

shouldComponentUpdate通过判断props.colorstate.count是否发生变化来决定需不需要重新渲染组件,当然有时候这种简单的判断,显得有些多余和样板化,于是React就提供了PureComponent来自动帮我们做这件事,这样就不需要手动来写shouldComponentUpdate了:

class CounterButton extends React.PureComponent {
  constructor(props) {
    super(props);
    this.state = {count: 1};
  }

  render() {
    return (
      <button
        color={this.props.color}
        onClick={() => this.setState(state => ({count: state.count + 1}))}>
        Count: {this.state.count}
      </button>
    );
  }
}

大多数情况下, 我们使用PureComponent能够简化我们的代码,并且提高性能,但是PureComponent的自动为我们添加的shouldComponentUpate函数,只是对props和state进行浅比较(shadow comparison),当props或者state本身是嵌套对象或数组等时,浅比较并不能得到预期的结果,这会导致实际的props和state发生了变化,但组件却没有更新的问题,例如下面代码有一个ListOfWords组件来将单词数组拼接成逗号分隔的句子,它有一个父组件WordAdder让你点击按钮为单词数组添加单词,但他并不能正常工作:

class ListOfWords extends React.PureComponent {
  render() {
    return <div>{this.props.words.join(',')}</div>;
  }
 }
 
class WordAdder extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      words: ['marklar']
    };
    this.handleClick = this.handleClick.bind(this);
  }
  
  handleClick() {
    // 这个地方导致了bug
    const words = this.state.words;
    words.push('marklar');
    this.setState({words: words});
  }

  render() {
    return (
      <div>
        <button onClick={this.handleClick} />
        <ListOfWords words={this.state.words} />
      </div>
    );
  }
}

这种情况下,PureComponent只会对this.props.words进行一次浅比较,虽然数组里面新增了元素,但是this.props.words与nextProps.words指向的仍是同一个数组,因此this.props.words !== nextProps.words 返回的便是flase,从而导致ListOfWords组件没有重新渲染,笔者之前就因为对此不太了解,而随意使用PureComponent,导致state发生变化,而视图就是不更新,调了好久找不到原因~。

最简单避免上述情况的方式,就是避免使用可变对象作为props和state,取而代之的是每次返回一个全新的对象,如下通过concat来返回新的数组:

handleClick() {
  this.setState(prevState => ({
    words: prevState.words.concat(['marklar'])
  }));
}

你可以考虑使用Immutable.js来创建不可变对象,通过它来简化对象比较,提高性能。
这里还要提到的一点是虽然这里虽然使用了Pure这个词,但是PureComponent并不是纯的,因为对于纯的函数或组件应该是没有内部状态,对于stateless component更符合纯的定义,不了解纯函数的同学,可以参见这篇文章

4.Stateless Functional Component

上面我们提到的创建组件的方式,都是用来创建包含状态和用户交互的复杂组件,当组件本身只是用来展示,所有数据都是通过props传入的时候,我们便可以使用Stateless Functional Component来快速创建组件。例如下面代码所示:

import React from 'react';
const Button = ({
  day,
  increment
}) => {
  return (
    <div>
      <button onClick={increment}>Today is {day}</button>
    </div>
  )
}

Button.propTypes = {
  day: PropTypes.string.isRequired,
  increment: PropTypes.func.isRequired,
}

这种组件,没有自身的状态,相同的props输入,必然会获得完全相同的组件展示。因为不需要关心组件的一些生命周期函数和渲染的钩子,所以不用继承自Component显得更简洁。

对比

createClass vs Component

对于React.createClassextends React.Component本质上都是用来创建组件,他们之间并没有绝对的好坏之分,只不过一个是ES5的语法,一个是ES6的语法支持,只不过createClass支持定义PureRenderMixin,这种写法官方已经不再推荐,而是建议使用PureComponent。

pureComponent vs Component

通过上面对PureComponent和Component的介绍,你应该已经了解了二者的区别:PureComponent已经定义好了shouldUpdateComponentComponent需要显示定义。

Component vs Stateless Functional component

  1. Component包含内部state,而Stateless Functional Component所有数据都来自props,没有内部state;

  2. Component 包含的一些生命周期函数,Stateless Functional Component都没有,因为Stateless Functional component没有shouldComponentUpdate,所以也无法控制组件的渲染,也即是说只要是收到新的props,Stateless Functional Component就会重新渲染。

  3. Stateless Functional Component不支持Refs

选哪个?

这里仅列出一些参考:

  1. createClass, 除非你确实对ES6的语法一窍不通,不然的话就不要再使用这种方式定义组件。

  2. Stateless Functional Component, 对于不需要内部状态,且用不到生命周期函数的组件,我们可以使用这种方式定义组件,比如展示性的列表组件,可以将列表项定义为Stateless Functional Component。

  3. PureComponent/Component,对于拥有内部state,使用生命周期的函数的组件,我们可以使用二者之一,但是大部分情况下,我更推荐使用PureComponent,因为它提供了更好的性能,同时强制你使用不可变的对象,保持良好的编程习惯。

参考文章

optimizing-performance.html#shouldcomponentupdate-in-action
pureComponent介绍
react-functional-stateless-component-purecomponent-component-what-are-the-dif
4 different kinds of React component styles
react-without-es6
react-create-class-versus-component

查看原文

布利丹牵驴子 关注了用户 · 2019-03-09

司徒正美 @situzhengmei

穿梭于二次元与二进制间的魔法师( ̄(工) ̄) 凸ส้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้้

关注 2152

认证与成就

  • 获得 103 次点赞
  • 获得 5 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 5 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2016-07-26
个人主页被 1.2k 人浏览