isNealyang

isNealyang 查看完整档案

杭州编辑东北大学秦皇岛分校  |  电子信息工程 编辑Alibaba  |  高级前端工程师 编辑填写个人主网站
编辑

微信公众号:全栈前端精选

每日获取第一手好文推送

个人动态

isNealyang 赞了文章 · 10月28日

【编译篇】AST实现函数错误的自动上报

前言

之前有身边有人问我在错误监控中,如何能实现自动为函数自动添加错误捕获。今天我们来聊一聊技术如何实现。先讲原理:在代码编译时,利用 babel 的 loader,劫持所有函数表达。然后利用 AST(抽象语法树) 修改函数节点,在函数外层包裹 try/catch。然后在 catch 中使用 sdk 将错误信息在运行时捕获上报。如果你对编译打包感兴趣,那么本文就是为你准备的。

本文涉及以下知识点:

  • [x] AST
  • [x] npm 包开发
  • [x] Babel
  • [x] Babel plugin
  • [x] Webpack loader

实现效果

Before 开发环境:

var fn = function(){
  console.log('hello');
}

After 线上环境:

var fn = function(){
+  try {
    console.log('hello');
+  } catch (error) {
+    // sdk 错误上报
+    ErrorCapture(error);
+  }
}

Babel 是什么?

Babel 是JS编译器,主要用于将 ECMAScript 2015+ 版本的代码转换为向后兼容的 JavaScript 语法,以便能够运行在当前和旧版本的浏览器或其他环境中。
简单说就是从一种源码到另一种源码的编辑器!下面列出的是 Babel 能为你做的事情:

  • 语法转换
  • 通过 Polyfill 方式在目标环境中添加缺失的特性 (通过 @babel/polyfill 模块)
  • 源码转换 (codemods)
  • 其它

Babel 的运行主要分三个阶段,请牢记:解析->转换->生成,后面会用到。

本文我们将会写一个 Babel plugin 的 npm 包,用于编译时将代码进行改造。

babel-plugin 环境搭建

这里我们使用 yeomangenerator-babel-plugin 来构建插件的脚手架代码。安装:

$ npm i -g yo
$ npm i -g generator-babel-plugin

然后新建文件夹:

$ mkdir babel-plugin-function-try-actch
$ cd babel-plugin-function-try-actch

生成npm包的开发工程:

$ yo babel-plugin


此时项目结构为:

babel-plugin-function-try-catch
├─.babelrc
├─.gitignore
├─.npmignore
├─.travis.yml
├─README.md
├─package-lock.json
├─package.json
├─test
|  ├─index.js
|  ├─fixtures
|  |    ├─example
|  |    |    ├─.babelrc
|  |    |    ├─actual.js
|  |    |    └expected.js
├─src
|  └index.js
├─lib
|  └index.js

这就是我们的 Babel plugin,取名为 babel-loader-function-try-catch为方便文章阅读,以下我们统一简称为plugin)。

至此,npm 包环境搭建完毕,代码地址

调试 plugin 的 ast

开发工具

本文前面说过 Babel 的运行主要分三个阶段:解析->转换->生成,每个阶段 babel 官方提供了核心的 lib:

  • babel-core。Babel 的核心库,提供了将代码编译转化的能力。
  • babel-types。提供 AST 树节点的类型。
  • babel-template。可以将普通字符串转化成 AST,提供更便捷的使用

plugin 根目录安装需要用到的工具包:

npm i @babel/core @babel/parser babel-traverse @babel/template babel-types -S

打开 plugin 的 src/index.js 编辑:

const parser = require("@babel/parser");

// 先来定义一个简单的函数
let source = `var fn = function (n) {
  console.log(111)
}`;

// 解析为 ast
let ast = parser.parse(source, {
  sourceType: "module",
  plugins: ["dynamicImport"]
});

// 打印一下看看,是否正常
console.log(ast);

终端执行 node src/index.js 后将会打印如下结果:

这就是 fn 函数对应的 ast,第一步解析完成!

获取当前节点的 AST

然后我们使用 babel-traverse 去遍历对应的 AST 节点,我们想要寻找所有的 function 表达可以写在 FunctionExpression 中:

打开 plugin 的 src/index.js 编辑:

const parser = require("@babel/parser");
const traverse = require("babel-traverse").default;

// mock 待改造的源码
let source = `var fn = function() {
  console.log(111)
}`;

// 1、解析
let ast = parser.parse(source, {
  sourceType: "module",
  plugins: ["dynamicImport"]
});

// 2、遍历
+ traverse(ast, {
+   FunctionExpression(path, state) { // Function 节点
+     // do some stuff
+   },
+ });

所有函数表达都会走到 FunctionExpression 中,然后我们可以在里面对其进行修改。
其中参数 path 用于访问到当前的节点信息 path.node,也可以像 DOM 树访问到父节点的方法 path.parent

修改当前节点的 AST

好了,接下来要做的是在 FunctionExpression 中去劫持函数的内部代码,然后将其放入 try 函数内,并且在 catch 内加入错误上报 sdk 的代码段。

获取函数体内部代码

上面定义的函数是

var fn = function() {
  console.log(111)
}

那么函数内部的代码块就是 console.log(111),可以使用 path 拿到这段代码的 AST 信息,如下:

const parser = require("@babel/parser");
const traverse = require("babel-traverse").default;

// mock 待改造的源码
let source = `var fn = function(n) {
  console.log(111)
}`;

// 1、解析
let ast = parser.parse(source, {
  sourceType: "module",
  plugins: ["dynamicImport"]
});

// 2、遍历
traverse(ast, {
  FunctionExpression(path, state) { // 函数表达式会进入当前方法
+    // 获取函数当前节点信息
+    var node = path.node,
+        params = node.params,
+        blockStatement = node.body,
+        isGenerator = node.generator,
+        isAsync = node.async;

+    // 可以尝试打印看看结果
+    console.log(node, params, blockStatement);
  },
});

终端执行 node src/index.js,可以打印看到当前函数的 AST 节点信息。

创建 try/catch 节点(两步骤)

创建一个新的节点可能会稍微陌(fu)生(za)一点,不过我已经为大家总结了我个人的经验(仅供参考)。首先需要知道当前新增代码段它的声明是什么,然后使用 @babel-types 去创建即可。

第一步:

那么我们如何知道它的表达声明type是什么呢?这里我们可以 使用 astexplorer 查找它在 AST 中 type 的表达

如上截图得知,try/catch 在 AST 中的 type 就是 TryStatement

第二步:

然后去 @babel-types 官方文档查找对应方法,根据 API 文档来创建即可。

如文档所示,创建一个 try/catch 的方式使用 t.tryStatement(block, handler, finalizer)

创建新的ast节点一句话总结:使用 astexplorer 查找你要生成的代码的 type,再根据 type 在 @babel-types 文档查找对应的使用方法使用即可!

那么创建 try/catch 只需要使用 t.tryStatement(try代码块, catch代码块) 即可。

  • try代码块 表示 try 中的函数代码块,即原先函数 body 内的代码 console.log(111),可以直接用 path.node.body 获取;
  • catch代码块 表示 catch 代码块,即我们想要去改造进行错误收集上报的 sdk 的代码 ErrorCapture(error),可以使用 @babel/template 去生成。

代码如下所示:

const parser = require("@babel/parser");
const traverse = require("babel-traverse").default;
const t = require("babel-types");
const template = require("@babel/template");

// 0、定义一个待处理的函数(mock)
let source = `var fn = function() {
  console.log(111)
}`;

// 1、解析
let ast = parser.parse(source, {
  sourceType: "module",
  plugins: ["dynamicImport"]
});

// 2、遍历
traverse(ast, {
  FunctionExpression(path, state) { // Function 节点
    var node = path.node,
        params = node.params,
        blockStatement = node.body, // 函数function内部代码,将函数内部代码块放入 try 节点
        isGenerator = node.generator,
        isAsync = node.async;

+    // 创建 catch 节点中的代码
+    var catchStatement = template.statement(`ErrorCapture(error)`)();
+    var catchClause = t.catchClause(t.identifier('error'),
+          t.blockStatement(
+            [catchStatement] //  catchBody
+          )
+        );
+    // 创建 try/catch 的 ast
+    var tryStatement = t.tryStatement(blockStatement, catchClause);
  }
});

创建新函数节点,并将上面定义好的 try/catch 塞入函数体:

const parser = require("@babel/parser");
const traverse = require("babel-traverse").default;
const t = require("babel-types");
const template = require("@babel/template");

// 0、定义一个待处理的函数(mock)
let source = `var fn = function() {
  console.log(111)
}`;

// 1、解析
let ast = parser.parse(source, {
  sourceType: "module",
  plugins: ["dynamicImport"]
});

// 2、遍历
traverse(ast, {
  FunctionExpression(path, state) { // Function 节点
      var node = path.node,
          params = node.params,
          blockStatement = node.body, // 函数function内部代码,将函数内部代码块放入 try 节点
          isGenerator = node.generator,
          isAsync = node.async;

      // 创建 catch 节点中的代码
      var catchStatement = template.statement(`ErrorCapture(error)`)();
      var catchClause = t.catchClause(t.identifier('error'),
            t.blockStatement(
              [catchStatement] //  catchBody
            )
          );
      // 创建 try/catch 的 ast
      var tryStatement = t.tryStatement(blockStatement, catchClause);

+    // 创建新节点
+    var func = t.functionExpression(node.id, params, t.BlockStatement([tryStatement]), isGenerator, isAsync);
+    // 打印看看是否成功
+    console.log('当前节点是:', func);
+    console.log('当前节点下的自节点是:', func.body);
  }
});

此时将上述代码在终端执行 node src/index.js

可以看到此时我们在一个函数表达式 body 中创建了一个 try 函数(TryStatement)。
最后我们需要将原函数节点进行替换:

const parser = require("@babel/parser");
const traverse = require("babel-traverse").default;
const t = require("babel-types");
const template = require("@babel/template");

// 0、定义一个待处理的函数(mock)
let source = `var fn = function() {...

// 1、解析
let ast = parser.parse(source, {...

// 2、遍历
traverse(ast, {
  FunctionExpression(path, state) { // Function 节点
      var node = path.node,
          params = node.params,
          blockStatement = node.body, // 函数function内部代码,将函数内部代码块放入 try 节点
          isGenerator = node.generator,
          isAsync = node.async;

      // 创建 catch 节点中的代码
      var catchStatement = template.statement(`ErrorCapture(error)`)();
      var catchClause = t.catchClause(t.identifier('error'),...

      // 创建 try/catch 的 ast
      var tryStatement = t.tryStatement(blockStatement, catchClause);
      // 创建新节点
      var func = t.functionExpression(node.id, params, t.BlockStatement([tryStatement]), isGenerator, isAsync);
      
+    // 替换原节点
+    path.replaceWith(func);
  }
});

+ // 将新生成的 AST,转为 Source 源码:
+ return core.transformFromAstSync(ast, null, {
+  configFile: false // 屏蔽 babel.config.js,否则会注入 polyfill 使得调试变得困难
+ }).code;

“A loader is a node module exporting a function”,也就是说一个 loader 就是一个暴露出去的 node 模块,既然是一个node module,也就基本可以写成下面的样子:

module.exports = function() {
    //  ...
};

再编辑 src/index.js 为如下截图:

边界条件处理

我们并不需要为所有的函数都增加 try/catch,所有我们还得处理一些边界条件。

  • 1、如果有 try catch 包裹了
  • 2、防止 circle loops
  • 3、需要 try catch 的只能是语句,像 () => 0 这种的 body
  • 4、如果函数内容小于多少行数

满足以上条件就 return 掉!

代码如下:

if (blockStatement.body && t.isTryStatement(blockStatement.body[0])
  || !t.isBlockStatement(blockStatement) && !t.isExpressionStatement(blockStatement)
  || blockStatement.body && blockStatement.body.length <= LIMIT_LINE) {
  return;
}

最后我们发布到 npm 平台 使用。

由于篇幅过长不易阅读,本文特别的省略了本地调试过程,所以需要调试请移步 [【利用AST自动为函数增加错误上报-续集】有关 npm 包的本地开发和调试]()。

如何使用

npm install babel-plugin-function-try-catch

webpack 配置

rules: [{
  test: /\.js$/,
  exclude: /node_modules/,
  use: [
+   "babel-plugin-function-try-catch",
    "babel-loader",
  ]
}]

效果见如下图所示:

最后

有关 npm 包的本地调试见下篇: 有关 npm 包的本地开发和调试

更多 AST 相关请关注后面分享,谢谢。

Reference:

完整代码地址请点击

Babel 插件手册点击

查看原文

赞 7 收藏 3 评论 0

isNealyang 发布了文章 · 9月18日

前端架构与实践

前文

从思考、到探索、到脚手架的产生,后面经过一系列的项目开发,不断优化和改良。目前已经成功应用到房产中间页(改名天猫房产)中。这里,做一下总结。

仅为抛砖,希望看完这个系列的同学可以相互探讨学习一下

为什么使用源码

目前,我们大多数页面,包括搜索页、频道页都是大黄蜂搭建的页面。至于搭建的优点,这里就不多赘述了。而我们使用源码编写,主要是基于以下几点思考:

  • 稳定性要求高
  • 页面模块多而不定
  • 快速回滚方案
  • 模块通信复杂

源码架构

架构图

架构图需要调整。此为稿图,位置放的有些不合理,表述不清

底层技术支撑主要采用 Rax1.0 + TypeScript + Jest 编码。通过 pmcli生成项目脚手架。脚手架提供基础的文件代码组织和组件。包括 ComponentscommonUtilsdocumentmodules等。当然,这些组件最终会被抽离到 puicomgroup 下。

再往上,是容器层。容器提供一些可插拔的 hooks 能力。并且根据 component 的配置来渲染不同的组件到页面中,首屏组件和按需加载组件。最后,支撑到每一个对应的页面里面。

分工组织

对于一个页面,无论是 react 还是 rax,其实都是 fn(x)=>UI 的过程。所以整理流程无非就是拿到接口属于渲染到 UI 中。所以对于中间页的架构而言也是如此。

首先拿到基本的接口数据,通过自定义的状态管理,挂载到全局 state 对应的组件名下。容器层通过组件的配置文件,渲染对应的组件。最终呈现出完成的一个页面。当然,其中会有一些额外的容器附属功能,比如唤起手淘、监听键盘弹起等这个按需插入对应 hooks 即可。属于业务层逻辑。

工程目录

工程结构

image.png

页面结构

image.png

模块结构


image.pngimage.pngimage.png

以上结构在之前文章中都有介绍到

补充

这里补充下动态加载,以及入口 index 的写法。理论上这部分,在使用这套架构的同学,无需关心

index.tsx

return (
    <H5PageContainer
      title={PAGE_TITLE}
      showPlaceHolder={isLoading}
      renderPlaceHolder={renderLoadingPage}
      renderHeader={renderHeader}
      renderFooter={renderFooter}
      toTopProps={{
        threshold: 400,
        bottom: 203,
      }}
      customStyles={{
        headWrapStyles: {
          zIndex: 6,
        },
      }}
    >
      {renderSyncCom(
        asyncComConfig,
        dao,
        dispatch,
        reloadPageNotRefresh,
        reloadTick
      )}
      {renderDemandCom(
        demandComConfig,
        offsetBottom,
        dao,
        dispatch,
        reloadPageNotRefresh,
        reloadTick
      )}
      <BottomAttention />
    </H5PageContainer>
  );

模块动态加载

/**
 * 按需按需加载容器组件
 *
 * @export
 * @param {*} props 按需加载的组件 props+path
 * @returns 需按需加载的子组件
 */
export default function(props: IWrapperProps) {
  const placeHolderRef: any = useRef(null);
  const { offsetBottom, ...otherProps } = props;
  const canLoad = useDemandLoad(offsetBottom, placeHolderRef);
  const [comLoaded, setComLoaded] = useState(false);

  // 加入 hasLoaded 回调
  const wrapProps = {
    ...otherProps,
    hasLoaded: setComLoaded,
  };

  return (
    <Fragment>
      <Picture
        x-if={!comLoaded}
        ref={placeHolderRef}
        style={{ width: 750, height: 500, marginTop: 20 }}
        source={{ uri: PLACEHOLDER_PIC }}
        resizeMode={"contain"}
      />
      <ImportWrap x-if={canLoad} {...wrapProps} />
    </Fragment>
  );
}

/**
 * 动态加载
 * @param props
 */
function ImportWrap(props: IWrapperProps) {
  const { path, ...otherProps } = props;
  const [Com, error] = useImport(path);
  if (Com) {
    return <Com {...otherProps} />;
  } else if (error) {
    console.error(error);
    return null;
  } else {
    return null;
  }
}

use-demand-load.ts

import { useState, useEffect } from 'rax';
import { px2rem } from '@ali/puicom-universal-common-unit';

/**
 * 判断组件按需加载,即将进去可视区
 */
export function useDemandLoad(offsetBottom, comRef): boolean {
    const [canLoad, setCanLoad] = useState(false);
    const comOffsetTop = px2rem(comRef?.current?.offsetTop || 0);

    useEffect(() => {
        if (canLoad) return;
        if (offsetBottom > comOffsetTop && !canLoad) {
            setCanLoad(true);
        }
    }, [offsetBottom]);

    useEffect(() => {
        setCanLoad(comRef?.current?.offsetTop < (screen.height || screen.availHeight || 0));
    }, [comRef]);

    return canLoad;
}

模块编写与状态分发

模块编写

types

编写模块数据类型

image.png

挂载到 dao(dataAccessObject) 下

image.png

统一导出
避免文件引入过多过杂
  • type/index.d.ts

image.png

reducers

编写模块对应reducer

image.png

在 daoReducer 中统一挂载

image.png

数据分发

image.png

componentConfig


image.png

此处 keyName 是 type/dao.d.ts 下声明的值。会进行强校验。填错则分发不到对应的组件中

image.png

component

image.png
数据在 props.dataSource

状态分发

  • 模块声明需要挂载到 type/dao.d.ts
  • reducer 需要 combinedao.reduer.ts
  • useDataInitdispatch 对应 Action
  • config 中配置 (才会被渲染到 UI)

Demo 演示

以弹层为例

image.png
将所有弹层看做为一个模块,只是内容不同而已。而内容,即为我们之前说的组件目录结构中的 components 内容

定义模块 Models

定义模块类型

编写模块属于类型

image.png

挂载到 dao 中

image.png

reducer

编写组件所需的 reducer

image.png

actions 的注释非常有必要

image.png

combine 到 dao 中

image.png

编写组件

image.png
image.png
image.png

组件编写

carbon.png

通信

导入对应 action

import { actions as modelActions } from "../../../reducers/models.reducer";
dispatch
 dispatch([modelActions.setModelVisible(true),modelActions.setModelType("setSubscribeType")])
触发 ts 校验

image.png
image.png

效果


页面容器

基于拍卖通用容器组件改造

改造点基于 body 滚动

因为我们目前页面都是 h5 页面了,之前则是 weex 的。所以对于容器的底层,之前使用的 RecycleView :固定 div 高度,基于 overflow 来实现滚动的。

虽然,在 h5 里面这种滚动机制有些”难受“,但是罪不至”换“。但是尴尬至于在于,iOS 的橡皮筋想过,在页面滚动到顶部以后,如果页面有频繁的动画或者 setState 的时候,会导致页面重绘,重新回到顶部。与手动下拉页面容器的橡皮筋效果冲突,而倒是页面疯狂抖动。所以。。。。重构。

旧版容器功能点

源码页面中使用的部分

重构后的使用

基本没有太大改变

简单拆解实现

type

import { FunctionComponent, RaxChild, RaxChildren, RaxNode, CSSProperties } from 'rax';

export interface IHeadFootWrapperProps {
    /**
     * 需要渲染的子组件
     */
    comFunc?: () => FunctionComponent | JSX.Element;
    /**
     * 组件类型
     */
    type: "head" | "foot",
    /**
     * 容器样式
     */
    wrapStyles?: CSSProperties;
}

/**
 * 滚动到顶部组件属性
 */
export interface IScrollToTopProps {
    /**
     * 距离底部距离
     */
    bottom?: number;
    /**
     * zIndex
     */
    zIndex?: number;
    /**
     * icon 图片地址
     */
    icon?: string;
    /**
     * 暗黑模式的 icon 图片地址
     */
    darkModeIcon?: string;
    /**
     * icon宽度
     */
    iconWidth?: number;
    /**
     * icon 高度
     */
    iconHeight?: number;
    /**
     * 滚动距离(滚动多少触发)
     */
    threshold?: number;
    /**
     * 点击回滚到顶部是否有动画
     */
    animated?: boolean;
    /**
     * 距离容器右侧距离
     */
    right?: number;
    /**
     * 展示回调
     */
    onShow?: (...args) => void;
    /**
     * 消失回调
     */
    onHide?: (...args) => void;
}
/**
 * 内容容器
 */
export interface IContentWrapProps{
    /**
     * children
     */
    children:RaxNode;
    /**
     * 隐藏滚动到顶部
     */
    hiddenScrollToTop?:boolean;
     /**
     * 返回顶部组件 Props
     */
    toTopProps?: IScrollToTopProps;
    /**
     * 渲染头部
     */
    renderHeader?: () => FunctionComponent | JSX.Element;
    /**
     * 渲染底部
     */
    renderFooter?: () => FunctionComponent | JSX.Element;
    /**
     * 自定义容器样式
     */
    customStyles?: {
        /**
         * body 容器样式
         */
        contentWrapStyles?: CSSProperties;
        /**
         * 头部容器样式
         */
        headWrapStyles?: CSSProperties;
        /**
         * 底部容器样式
         */
        bottomWrapStyle?: CSSProperties;
    };
    /**
     * 距离底部多少距离开始触发 endReached
     */
    onEndReachedThreshold?: number;
}

export interface IContainerProps extends IContentWrapProps {
    /**
     * 页面标题
     */
    title: string;
    /**
     * 页面 placeHolder
     */
    renderPlaceHolder?: () => FunctionComponent | JSX.Element;
    /**
     * 是否展示 placeH
     */
    showPlaceHolder?: boolean;
}

index.tsx

const isDebug = isTrue(getUrlParam('pm-debug'));
export default function({
  children,
  renderFooter,
  renderHeader,
  title,
  onEndReachedThreshold = 0,
  customStyles = {},
  toTopProps = {},
  showPlaceHolder,
  renderPlaceHolder,
  hiddenScrollToTop=false
}: IContainerProps) {
  if (!isWeb) return null;

  // 监听滚动
  useListenScroll();
  // 设置标题
  useSetTitle(title);
  // 监听 error 界面触发
  const { errorType } = useListenError();

  return (
    <Fragment>
      <ContentWrap
        x-if={errorType === "" && !showPlaceHolder}
        renderFooter={renderFooter}
        customStyles={customStyles}
        renderHeader={renderHeader}
        onEndReachedThreshold={onEndReachedThreshold}
        toTopProps={toTopProps}
        hiddenScrollToTop={hiddenScrollToTop}
      >
        {children}
      </ContentWrap>
      {renderPlaceHolder && showPlaceHolder && renderPlaceHolder()}
      <ErrorPage type={errorType} x-if={errorType} />
      <VConsole x-if={isDebug}/>
    </Fragment>
  );
}

export { APP_CONTAINER_EVENTS };

通过 Fragment 包裹,主题是 ContentWrapErrorPageVConsoleHolder放置主体以外。

相关 hooks 功能点完全区分开来

广播事件

/**
 * Events 以页面为单位
 */
export const APP_CONTAINER_EVENTS = {
    SCROLL: 'H5_PAGE_CONTAINER:SCROLL',
    TRIGGER_ERROR: 'H5_PAGE_CONTAINER:TRIGGER_ERROR',
    END_REACHED: 'H5_PAGE_CONTAINER:END_REACHED',
    HIDE_TO_TOP: 'H5_PAGE_CONTAINER:HIDE_TO_TOP',
    RESET_SCROLL: 'H5_PAGE_CONTAINER:RESET_SCROLL',
    ENABLE_SCROLL:"H5_PAGE_CONTAINER:H5_PAGE_CONTAINER"
}

pm-cli

详见:pm-cli脚手架,统一阿里拍卖源码架构

安装:tnpm install -g @ali/pmcli

help

这里在介绍下命令:

基本使用

pmc init

  • 在空目录中调用,则分两步工作:

    • 首先调用 tnpm init rax 初始化出来 rax 官方脚手架目录
    • 修改 package.jsonname 为当前所在文件夹的文件夹名称
    • 升级为拍卖源码架构,下载对应脚手架模板:init-project
  • 在已init rax后的项目中调用

    • 升级为拍卖源码架构,下载对应脚手架模板:init-project
注意:经过 pmc 初始化的项目,在项目根目录下回存有.pm-cli.config.json 配置文件

pmc add-page

在当前 项目中新增页面,选择三种页面类型

img

推荐使用 simpleSourcecustomStateManage

页面模板地址:add-page

pmc add-mod

根据所选择页面,初始化不同类型的模块

模块模板地址为:add-mod

pmc init-mod

调用def init tbe-mod,并且将仓库升级为支持 ts 开发模式

pmc publish-init

发布端架构初始化,基于 react 应用

发布端架构模板地址:publish-project

pmc publish-add

添加发布端模块

模块模板地址:publish-mod

pmc init-mod

调用 def init tbe-mod 命令,并同时升级为 ts 编码环境。

配置环境、安装依赖、直接运行

相关体验地址(部分无法访问)

  • 阿里房产
  • 底层容器 (单独抽离组件ing)
  • pmCli
  • ts tbeMod

技术交流

公众号【全栈前端精选】回复【1】即可入“全栈前端 5 群”群

查看原文

赞 1 收藏 0 评论 0

isNealyang 发布了文章 · 7月8日

从零手写pm-cli脚手架,统一阿里拍卖源码架构

前言

原文地址:https://github.com/Nealyang/PersonalBlog/issues/72

脚手架其实是大多数前端都不陌生的东西,基于前面写过的两篇文章:

大概呢,就是介绍下,目前我的几个项目页面的代码组织形式。

用了几个项目后,发现也挺顺手,遂想着要不搞个 cli 工具,统一下源码的目录结构吧。

这样不仅可以减少一个机械的工作同时也能够统一源码架构。同学间维护项目的陌生感也会有所降低。的确是有一部分提效的不是。虽然我们大多数页面都走的大黄蜂搭建🥺。。。

功能

cli 工具其实就一些基本的命令运行、CV 大法,没有什么技术深度。

bin

效果

bin

工程目录

工程目录

代码实现

  • bin/index.js
#!/usr/bin/env node

'use strict';

const currentNodeVersion = process.versions.node;
const semver = currentNodeVersion.split('.');
const major = semver[0];

if (major < 10) {
  console.error(
    'You are running Node ' +
      currentNodeVersion +
      '.\n' +
      'pmCli requires Node 10 or higher. \n' +
      'Please update your version of Node.'
  );
  process.exit(1);
}

require('../packages/initialization')();

这里是入口文件,比较简单,就是配置个入口,顺便校验 node 的版本号

  • initialization.js

这个文件主要是配置一些命令,其实也比较简单,大家从 commander里面查看自己需要的配置,然后配置出来就可以了

就是根据自己需求去配置这里就不赘述了,除了以上,就以下两点实现:

  • 功能入口
 // 创建工程
  program
    .usage("[command]")
    .command("init")
    .option("-f,--force", "overwrite current directory")
    .description("initialize your project")
    .action(initProject);

  // 新增页面
  program
    .command("add-page <page-name>")
    .description("add new page")
    .action(addPage);

  // 新增模块
  program
    .command("add-mod [mod-name]")
    .description("add new mod")
    .action(addMod);

  // 添加/修改 .pmConfig.json
  program
    .command("modify-config")
    .description("modify/add config file (.pmCli.config)")
    .action(modifyCon);

  program.parse(process.argv);
  • 兜底

所谓兜底就是输入 pm-cli 后没有跟任何命令

pm-cli init

在说 init 之前呢,这里有个技术背景。就是我们的 rax 工程,基于 def 平台初始化出来的,所以说自带一个脚手架。但是我们在源码开发中呢,会对其进行一些改动。为了避免认知重复呢,init 我分为两个功能:

  • init projectName 从 0 创建一个def init rax projectName 项目
  • 在 raxProject 里面 init 会基于当前架构补充我们所统一的源码架构

流程

init projectName

这里我们在一个空目录中进行演示

initProject

运行结束图

init

init

至于这里的一些问题的交互就不介绍了,就是inquirer配置的一些问题而已。没有太大的参考价值 。

initProject

入口

入口方法较为简单,其实就是区分当前运行 pm-cli init到底是基于已有项目初始化,还是新建一个 rax 项目 ,判断依据也非常简单,就是判断当前目录下是否有 package.json

虽然这么判断感觉是草率了点,但是,你细品也确实如此!对于有 package.json 的当前目录,我还会去校验别的不是。

如果当前目录存在 package.json,那么我认为你是一个项目,想在此项目中,初始化拍卖源码架构的配置。所以我会去判断当前项目是否已经初始化过了。

fs.existsSync(path.resolve(CURR_DIR, `./${PM_CLI_CONFIG_FILE_NAME}`))

也就是这个PM_CLI_CONFIG_FILE_NAME的内容。那么则给出提示。毕竟不需要重复初始化嘛。如果你想强行再初始化一次,也可以!

pm-cli init -f

准备工作坐在前期,最终运行的功能都在 run 方法里面。

校验名称合法性

这里还有个功能函数非常的通用,也就提前拿出来说了吧。

const dirList = fs.readdirSync(CURR_DIR);

checkNameValidate(projectName, dirList);

/**
 * 校验名称合法性
 * @param {string} name 传入的名称 modName/pageName
 * @param {Array}} validateNameList 非法名数组
 */
const checkNameValidate = (name, validateNameList = []) => {
  const validationResult = validatePageName(name);
  if (!validationResult.validForNewPackages) {
    console.error(
      chalk.red(
        `Cannot create a mod or page named ${chalk.green(
          `"${name}"`
        )} because of npm naming restrictions:\n`
      )
    );
    [
      ...(validationResult.errors || []),
      ...(validationResult.warnings || []),
    ].forEach((error) => {
      console.error(chalk.red(`  * ${error}`));
    });
    console.error(chalk.red("\nPlease choose a different project name."));
    process.exit(1);
  }
  const dependencies = [
    "rax",
    "rax-view",
    "rax-text",
    "rax-app",
    "rax-document",
    "rax-picture",
  ].sort();
  validateNameList = validateNameList.concat(dependencies);

  if (validateNameList.includes(name)) {
    console.error(
      chalk.red(
        `Cannot create a project named ${chalk.green(
          `"${name}"`
        )} because a page with the same name exists.\n`
      ) +
        chalk.cyan(
          validateNameList.map((depName) => `  ${depName}`).join("\n")
        ) +
        chalk.red("\n\nPlease choose a different name.")
    );
    process.exit(1);
  }
};

其实就是校验名称合法性以及排除重名。这个工具函数可以直接 CV。

如上的流程图,我们已经走到run 方法了,剩下的就是里面的一些判断。

  const packageObj = fs.readJSONSync(path.resolve(CURR_DIR, "./package.json"));
  // 判断是 rax 项目
  if (
    !packageObj.dependencies ||
    !packageObj.dependencies.rax ||
    !packageObj.name
  ) {
    handleError("必须在 rax 1.0 项目中初始化");
  }
  // 判断 rax 版本
  let raxVersion = packageObj.dependencies.rax.match(/\d+/) || [];
  if (raxVersion[0] != 1) {
    handleError("必须在 rax 1.0 项目中初始化");
  }

  if (!isMpaApp(CURR_DIR)) {
    handleError(`不支持非 ${chalk.cyan('MPA')} 应用使用 pmCli`);
  }

因为这些判断也不是非常的具有参考价值,这里就简单跳过了,然后在重点介绍下一些公共方法的编写。

addTsConfig

/**
 * 判断目标项目是否为 ts,并创建配置文件
 */
function addTsconfig() {
  let distExist, srcExist;
  let disPath = path.resolve("./tsconfig.json");
  let srcPath = path.resolve(__dirname, "../../ts.json");

  try {
    distExist = fs.existsSync(disPath);
  } catch (error) {
    handleError("路径解析发生错误 code:0024,请联系@一凨");
  }
  if (distExist) return;
  try {
    srcExist = fs.existsSync(srcPath);
  } catch (error) {
    handleError("路径解析发生错误 code:1233,请联系@一凨");
  }
  if (srcExist) {
    // 本地存在
    console.log(
      chalk.red(`编码语言请采用 ${chalk.underline.red("Typescript")}`)
    );
    spinner.start("正在为您创建配置文件:tsconfig.json");
    fs.copy(srcPath, disPath)
      .then(() => {
        console.log();
        spinner.succeed("已为您创建 tsconfig.json 配置文件");
      })
      .catch((err) => {
        handleError("tsconfig 创建失败,请联系@一凨");
      });
  } else {
    handleError("路径解析发生错误 code:2144,请联系@一凨");
  }
}

上面的代码大家都能读的懂,粘贴这一段代码的目的就是,希望大家写cli 的时候,一定要多考虑边界情况,存在性判断,以及一些异常兜底。避免不必要的 bug 产生

rewriteAppJson

/**
 * 重写项目中的 app.json
 * @param {string} distAppJson app.json 路径
 */
function rewriteAppJson(distAppPath) {
  try {
    let distAppJson = fs.readJSONSync(distAppPath);
    if (
      distAppJson.routes &&
      Array.isArray(distAppJson.routes) &&
      distAppJson.routes.length === 1
    ) {
      distAppJson.routes[0] = Object.assign({}, distAppJson.routes[0], {
        title: "阿里拍卖",
        spmB: "B码",
        spmA: "A码",
      });

      fs.writeJSONSync(path.resolve(CURR_DIR, "./src/app.json"), distAppJson, {
        spaces: 2,
      });
    }
  } catch (error) {
    handleError(`重写 ${chalk.cyan("app.json")}出错了,${error}`);
  }
}

别的重写方法就不粘贴了,因为也是比较枯燥且重复的。下面说一下公共方法和用处吧

下载模板

const templateProjectPath = path.resolve(__dirname, `../temps/project`);
// 下载模板
await downloadTempFromRep(projectTempRepo, templateProjectPath);
/**
 *从远程仓库下载模板
 * @param {string} repo 远程仓库地址
 * @param {string} path 路径
 */
const downloadTempFromRep = async (repo, srcPath) => {
  if (fs.pathExistsSync(srcPath)) fs.removeSync(`${srcPath}`);

  await seriesAsync([`git clone ${repo} ${srcPath}`]).catch((err) => {
    if (err) handleError(`下载模板出错:errorCode:${err},请联系@一凨`);
  });
  if(fs.existsSync(path.resolve(srcPath,'./.git'))){
    spinner.succeed(chalk.cyan('模板目录下 .git 移除'));
    fs.remove(path.resolve(srcPath,'./.git'));
  }
};

下载模板这里我直接用的 shell 脚本,因为这里涉及到很多权限的问题。

shell

// execute a single shell command where "cmd" is a string
exports.exec = function (cmd, cb) {
  // this would be way easier on a shell/bash script :P
  var child_process = require("child_process");
  var parts = cmd.split(/\s+/g);
  var p = child_process.spawn(parts[0], parts.slice(1), { stdio: "inherit" });
  p.on("exit", function (code) {
    var err = null;
    if (code) {
      err = new Error(
        'command "' + cmd + '" exited with wrong status code "' + code + '"'
      );
      err.code = code;
      err.cmd = cmd;
    }
    if (cb) cb(err);
  });
};

// execute multiple commands in series
// this could be replaced by any flow control lib
exports.seriesAsync = (cmds) => {
  return new Promise((res, rej) => {
    var execNext = function () {
      let cmd = cmds.shift();
      console.log(chalk.blue("run command: ") + chalk.magenta(cmd));
      shell.exec(cmd, function (err) {
        if (err) {
          rej(err);
        } else {
          if (cmds.length) execNext();
          else res(null);
        }
      });
    };
    execNext();
  });
};

copyFiles

/**
 * 拷贝页面s
 * @param {array} filesArr 文件数组,二维数组
 * @param {function} errorCb 失败回调函数
 * @param {成功回调函数} successCb 成功回调函数
 */
const copyFiles = (filesArr, errorCb, successCb) => {
  try {
    filesArr.map((filePathArr) => {
      if (filePathArr.length !== 2) throw "配置文件读写错误!";
      fs.copySync(filePathArr[0], filePathArr[1]);
      spinner.succeed(chalk.cyan(`${path.basename(filePathArr[1])} 初始化完成`));
    });
  } catch (error) {
    console.log(error);

    errorCb(error);
  }
};

在将远程代码拷贝到源码目录 temps/下,进行一波修改后,还是需要 copy 到项目目录中的,所以这里封装了一个方法。

配置文件

配置文件是我为了标识出当前项目,是否为 pmCli 初始化所得。因为在addPage 的时候,page 中的一些页面会使用到外部的组件,比如 loadingPage

配置文件

如上,initProject:true|false用来标识当前仓库。

[pageName] 用来表示有哪些页面是用 pmCli 新建的。属性 type:'simpleSource'|'withContext'|'customStateManage'则用来告诉后续 add-mod 到底添加哪种类型的模块。

同时呢,对内容进行了加密,因为配置页面,是放在用户的项目下的

配置文件

加密

const crypto = require('crypto');
function aesEncrypt(data) {
    const cipher = crypto.createCipher('aes192', 'PmCli');
    var crypted = cipher.update(data, 'utf8', 'hex');
    crypted += cipher.final('hex');
    return crypted;
}

function aesDecrypt(encrypted) {
    const decipher = crypto.createDecipher('aes192', 'PmCli');
    var decrypted = decipher.update(encrypted, 'hex', 'utf8');
    decrypted += decipher.final('utf8');
    return decrypted;
}
module.exports = {
    aesEncrypt,
    aesDecrypt
}

基本上如上,初始化项目的功能就介绍完了,后面的功能都是换汤不换药的这些操作。咱们走马观花,提个要点。

pm-cli add-page

addSimplePage

detail

生成的目录

流程图

流程图

上面的功能,其实就是跟 initProject里面的代码相似,就是一些“业务”情况的判断不同而已。

pm-cli add-mod

自定义状态管理模块

简单源码模块

新增的模块

其实模块的新增也没有特别的技术点。先选择页面列表,然后读取.pmCli.config中的页面的类型。根据类型去新增页面

function run(modName) {
  // 新增模块,需要定位当前位置
  modifiedCurrPathAndValidatePro(CURR_DIR);
  // 选择能够新增模块的页面
  pageList = Object.keys(pmCliConfigFileContent).filter((val) => {
    return val !== "initProject";
  });
  if (pageList.length === 0) {
    handleError();
  }

  inquirer.prompt(getQuestions(pageList)).then((answer) => {
    const { pageName } = answer;
    // modName 重名判断
    try {
      checkNameValidate(
        modName,
        fs.readdirSync(
          path.resolve(CURR_DIR, `./src/pages/${pageName}/components`)
        )
      );
    } catch (error) {
      console.log("读取当前页面模块列表失败", error);
    }

    let modType = pmCliConfigFileContent[pageName].type;
    inquirer.prompt(getInsureQuestions(modType)).then(async (ans) => {
      if (!ans.insure) {
        modType = ans.type;
      }
      const distPath = path.resolve(
        CURR_DIR,
        `./src/pages/${pageName}/components`
      );
      const tempPath = path.resolve(__dirname, "../temps/mod");
      // 下载模板
      await downloadTempFromRep(modTempRepo, tempPath);
      try {
        if (fs.existsSync(distPath)) {
          console.log(chalk.cyanBright(`开始进行模块初始化`));
          let copyFileArr = [
            [
              path.resolve(tempPath, `./${modType}`),
              path.resolve(distPath, `./${modName}`),
            ],
          ];
          if(modType === 'customStateManage'){
            copyFileArr = [
              [
                path.resolve(tempPath,`./${modType}/mod-com`),
                path.resolve(distPath,`./${modName}`)
              ],
              [
                path.resolve(tempPath,`./${modType}/mod-com.d.ts`),
                path.resolve(distPath,`../types/${modName}.d.ts`)
              ],
              [
                path.resolve(tempPath,`./${modType}/mod-com.reducer.ts`),
                path.resolve(distPath,`../reducers/${modName}.reducer.ts`)
              ],
            ]
          }
          copyFiles(copyFileArr, (err) => {
            handleError(`拷贝配置文件失败`, err);
          });
          if (!ans.insure) {
            console.log();
            console.log(
              chalk.underline.red(
                ` 请确认页面:${pageName},在 .pmCli.config 中的类型`
              )
            );
            console.log();
          }
          modAddEndConsole(modName,modType);
        } else {
          handleError("本地文件目录有问题");
        }
      } catch (error) {
        handleError("读取文件目录出错,请联系@一凨");
      }
    });
  });
}

矫正 CURR_DIR

在添加模块的时候,我还做了个人性化处理。防止好心人以为要到 cd 到指定 pages 下才能 addMod,所以我支持只要你在 srcpages 或者项目根目录下,都可以执行 add-mod

/**
 * 纠正当前路径到项目路径下,主要是为了防止用户在当前页面新建模块
 */
const modifiedCurrPathAndValidatePro = (proPath) => {
  const configFilePath = path.resolve(CURR_DIR, `./${PM_CLI_CONFIG_FILE_NAME}`);
  try {
    if (fs.existsSync(configFilePath)) {
      pmCliConfigFileContent = JSON.parse(
        aesDecrypt(fs.readFileSync(configFilePath, "utf-8"))
      );
      if (!isTrue(pmCliConfigFileContent.initProject)) {
        handleError(`配置文件:${PM_CLI_CONFIG_FILE_NAME}被篡改,请联系@一凨`);
      }
    } else if (
      path.basename(CURR_DIR) === "pages" ||
      path.basename(CURR_DIR) === "src"
    ) {
      CURR_DIR = path.resolve(CURR_DIR, "../");
      modifiedCurrPathAndValidatePro(CURR_DIR);
    } else {
      handleError(`当前项目并非${chalk.cyan("pm-cli")}初始化,不可使用该命令`);
    }
  } catch (error) {
    handleError("读取项目配置文件失败", error);
  }
};

pm-cli modify-config

因为之前介绍过源码的页面架构,同时我也应用到了项目开发中。开发 pmCli 的时候,又新增了新增了配置文件,存在本地还是加密的。那么岂不是我之前的项目需要新增页面还不能用这个 pmCli

所以,就新增了这个功能:

modify-config:

  • 当前项目是否存在 pmCli,没有则新建,有,则修改

注意点(总结)

  • cli 其实就是个简单的 node 小应用。fs-extra + shell就能玩起来,非常简单
  • 边界情况以及各种人性化的交互需要考虑周到
  • 异常处理和异常反馈需要给足
  • 无聊且重复的工作。当然,你可以发挥你的想象

THE LAST TIME

THE LAST TIME

TODO

  • 集成发布端脚手架(React)
  • 支持参数透传
  • vscode 插件,面板化操作

工具

所谓工欲善其事必先利其器,在 cli 避免不了使用非常多的工具,这里我主要是使用一些开源包以及从 CRA 里面 copy 过来的方法。

commander

homePage:https://github.com/tj/command...

node.js 命令行接口的完整解决方案

Inquirer

homePage:https://github.com/SBoudrias/...

交互式命令行用户界面的组件

fs-extra

homePage:https://github.com/jprichards...

fs 模块自带文件模块的外部扩展模块

semver

homePage:https://github.com/npm/node-s...

用于对版本的一些操作

chalk

homePage:https://github.com/chalk/chalk

在命令行中给文本添加颜色的组件

clui

spinners、sparklines、progress bars图样显示组件

homPage:https://github.com/nathanpeck...

download-git-repo

homePage:https://gitlab.com/flippidipp...

Node 下载并提取一个git仓库(GitHub,GitLab,Bitbucket)

ora

homePage:https://github.com/sindresorh...

命令行加载效果,同上一个类似

shelljs

homePage:https://github.com/shelljs/sh...

Node 跨端运行 shell 的组件

validate-npm-package-name

homePage:https://github.com/npm/valida...

用于检查包名的合法性

blessed-contrib

homePage:https://github.com/yaronn/ble...

命令行可视化组件

本来这些工具打算单独写一篇文章的,但是堆 list 的文章的确不是很有用。容易忘主要是,所以这里就带过了。功能和效果,大家自行查看和测试吧。然后 CRA 中的比较不错的方法,我也在文章末尾列出来了。关于 CRA 的源码阅读,也可以查看我以往的文章:github/Nealyang

CRA 中不错的方法/包

  • commander:概述一下,Node命令接口,也就是可以用它代管Node命令。npm地址
  • envinfo:可以打印当前操作系统的环境和指定包的信息。 npm地址
  • fs-extra:外部依赖,Node自带文件模块的外部扩展模块 npm地址
  • semver:外部依赖,用于比较Node版本 npm地址
  • checkAppName():用于检测文件名是否合法,
  • isSafeToCreateProjectIn():用于检测文件夹是否安全
  • shouldUseYarn():用于检测yarn在本机是否已经安装
  • checkThatNpmCanReadCwd():用于检测npm是否在正确的目录下执行
  • checkNpmVersion():用于检测npm在本机是否已经安装了
  • validate-npm-package-name:外部依赖,检查包名是否合法。npm地址
  • printValidationResults():函数引用,这个函数就是我说的特别简单的类型,里面就是把接收到的错误信息循环打印出来,没什么好说的。
  • execSync:引用自child_process.execSync,用于执行需要执行的子进程
  • cross-spawnNode跨平台解决方案,解决在windows下各种问题。用来执行node进程。npm地址
  • dns:用来检测是否能够请求到指定的地址。npm地址

参考

技术交流

全栈前端交流群④群

查看原文

赞 16 收藏 8 评论 2

isNealyang 发布了文章 · 5月18日

拍卖源码架构在拍品详情页上的探索

前言

原文地址:github/Nealyang

没有想到之前写的一篇一张页面引起的前端架构思考还收到不少同学关注。的确,正如之前在群里所说,一个系统能有一个非常好的架构设计。但是仅仅对于前端项目页面,其实很难把架构一词搬出来聊个天花乱坠。

但是!好的代码结构的组织的确能够避免一些不必要的采坑。当然,这其中也不乏对前端工程师的工程师素养约束。

一言以蔽之,对于前端项目的架构(代码组织)而言,,好不到哪里去。但是,却可以令人头皮发麻。

当然。。。我还是在尽可能的希望好~这也是这篇文章的目的所在。此处权且抛个砖,如果你有更好的见解和想法,欢迎随时交流~

拍卖详情页

详情页

详情页

图上的点我会在下文中挨个介绍

架构设计图

架构设计图

特点

  • 稳定性要求极高 (这一点区分手淘和天猫,毕竟_拍卖_...你品)
  • 需要详细的日志打点
  • 模块之间的通信非常多(拍品状态、倒计时、出价等)

对于手淘和天猫的商品,一般都是多个人对多个物品。即使出了问题,也不影响购买,大不了问题修复再购买(最坏的情况)。

但是对于拍卖的拍品。对多对一、价高者得的属性。并且具有一定的法律效应。所以稳定性的要求极其之高。同时拍卖又具有非常高时效性要求,所以 apush、轮询啥的都要求实时更新拍品的状态。

综合以上因素的考虑。最终我们没有选择大黄蜂搭建页面的形式构建起详情页。就先走了源码链路的开发。至于后续是否会推进落地,可能还有待商榷

整体架构

如果你阅读过上一篇文章一张页面引起的前端架构思考,那么可能会对接下来要介绍的目录组织结构比较熟悉。下面简单介绍下改动的部分以及添加的一些东西。

项目级别

目录的职责划分在之前的一篇文章中已经都介绍到了。这里就说下目前的一些改动点:

  • 新增 count-dow
  • 新增loop
  • 移除EVENTS

Count-downloop 都是详情页强相关的,但是由于项目名称为 pm-detail 所以,这里就提到 pages 以外的了。其实提不提的原则很简单。该文件是否可(需)共用

也是秉持着上面的原则,将 EVENTS 文件夹修改到页面容器里面了。毕竟,跨页面的广播需求基本是不存在的。

关于页面容器的介绍,也在之前的一篇《Decorator+TS装饰你的代码》一文中介绍到。这里也不赘述了。

count-down 的简单抽离

倒计时的“递归”交给 RAF 搞定。当然,这里是CountDown上的一个方法。

`/**

  • 开启倒计时

*/
start() {
let that = this;
function rafCallback() {
that.time -= new Date().getTime() - that.lastTime;
that.lastTime = new Date().getTime();
if (that.time < 0) {
that.time = 0;
}
that.updateCallback(that.time);
that.countDownRaf = window.requestAnimationFrame(rafCallback);
if (that.time <= 0) {
window.cancelAnimationFrame(that.countDownRaf);
if (that.endCallback) {
that.endCallback();
}
}
}
rafCallback();
}`

具体的倒计时和轮询的编写会在下一篇文章中介绍(内网)

count-down 的内部消费

`export const useInitCountDown = (
countDownData: IFormattedCountDown,
countEndCallback: () => any
) => {
let countDownRef = useRef(null) as any;
const [leftTime, setFormattedTime] = useState(countDownData.leftSwitchTime);
useEffect(() => {
if (countDownData.countDownSwitch) {
// 开启显示倒计时
countDownRef.current = startCountDown(
leftTime,
setFormattedTime,
countEndCallback
) ;
} else if (countDownData.implicitCountDownSwitch) {
// 开启隐藏倒计时
countDownRef.current = startImplicitCountDown(
leftTime,
countEndCallback,
(err) => {
console.log(err);
}
);
}
}, []);
useEffect(()=>{
countDownRef.current?.setTime(countDownData.leftSwitchTime);
},[countDownData.leftSwitchTime])
return leftTime;
};`

具体的代码就不解释了,涉及到太多的业务。后面单独写一篇记录

消费端是在 pages/detial/count-down/customized-hooks/use-init-count-down.ts (强关联业务)里面。

pages/detail

`detail
├─ components // 页面级别的 componets
│ ├─ bottom-action // 底部按钮模块
│ │ ├─ index.less
│ │ └─ index.tsx
│ ├─ config.ts // 模块的配置文件
│ ├─ count-down // 倒计时模块
│ │ ├─ customized-hooks // 倒计时模块的自定义 hooks
│ │ ├─ index.less
│ │ ├─ index.tsx
│ │ └─ utils // 倒计时模块
│ └─ loop // 倒计时模块
│ └─ index.tsx
├─ constants // 页面级别的常量定义
│ ├─ api.ts
│ ├─ common.ts
│ └─ spm.ts
├─ customized-hooks // 页面级别的自定义 hooks
│ └─ use-data-init.ts
├─ index.less
├─ index.tsx // 页面的入口文件
├─ reducers // reducer 目录(文件组织关联到 state 的设计)
│ ├─ count-down.reducer.ts // count-down 模块对应的 reducer
│ ├─ detail.reducer.ts // 汇总所有的组件的 reducer 到 detail 里面,并且包含一个公共的状态
│ ├─ index.ts // 整个页面的state
│ └─ loop.reducer.ts // 对应
├─ redux-middleware // redux 的中间件
│ ├─ redux-action-log // actionLog 中间件
│ │ └─ index.ts
│ └─ redux-mutli-action // 支持发送多个 action 的中间件
│ └─ index.ts
├─ types // 数据类型统一定义
│ ├─ count-down.d.ts
│ ├─ index.d.ts
│ ├─ item-dao.d.ts
│ ├─ loop.d.ts
│ └─ reducer-types.d.ts
├─ use-redux // 页面的状态管理
│ ├─ combineReducers.ts
│ ├─ compose.ts
│ ├─ redux.ts
│ ├─ types
│ │ ├─ actions.d.ts
│ │ └─ reducers.d.ts
│ └─ utils
│ ├─ actionTypes.ts
│ └─ warning.ts
└─ utils // 页面的工具函数
├─ demand-load-wrapper.tsx // 按需加载容器
└─ index.ts // 工具函数`

关于文件和目录的说明都写在了上面的注释中。对于后续的开发者需要重点关注的是:

  • components(包括 config)模块的组织
  • reducer 状态的组织
  • type 类型的约束
下面按个展开介绍

状态管理 useRedux

因为详情页的状态管理较为复杂,模块之间的通信也是非常频繁。所以这里我们需要引入 redux 作为状态管理。

虽然 hooks 里面已经提供了 useReducer ,但是却没有周边的“原生生态”: combineReducersMiddleware 等。所以我们将轮子搬一下,取名为:useRedux

关于 redux 的介绍可见:《从 redux 中搬个轮子给源码项目做状态管理》

这里重点介绍在这个项目中的使用契约:

基本使用

浪浪额够的时候写过一篇文章react技术栈项目结构探究) ,那时候我就非常喜欢将 redux 中的 initStateactionTypesactions以及 reducer 定义到一个文件中,的确非常的清晰方便。所以这里 reducers 文件夹也是如此。

每一个文件,对应每一个功能区域的 reducer

而 reducer 内部的组成,基本都是如下:

reducer 内部结构

reducer 内部结构

以上是模块的 reducer,对于开发者还需要知道的是模块的 reducer 需要插到 detail 里面:

`export const detailReducer = combineReducers<ICombineItemDo>({
countDown,
loop,
detailCommon: globalStateReducer,
});`

ICombineItemDo 会在下文的 Ts 状态约束里面介绍

所以如上的代码组成的最终页面 state 是如下结构

`{
pageState:{
isLoading:boolean
},
itemDo:{
countDown:ICountDown,
detailCommon:IDetailCommon,
loop:ILoop
}
}`

itemDo 其实应该命名为 itemDao但是由于 itemDo 我们用了五年了。。。尊重习惯的力量,避免不必要的麻烦

中间件的使用

虽然使用了中间件,但是跟 redux 还是有些不同的。具体的 applyMiddleware 就不说了,其实就是compose func 然后增强下 dispatch

`export const useRedux = (reducer: Reducer, ...middleWares: Function[]) => {
const [state, dispatch] = useReducer(reducer, {});
let newDispatch;
if (middleWares.length > 0) {
newDispatch = compose(...middleWares)(dispatch);
}
useEffect(() => {
dispatch({
type: ActionTypes.INIT
});
}, []);
return {
state, dispatch: newDispatch
}
}`

所以这里的中间件都是根据当前 dispatch 的 action 里面的 data 来执行相关操作的。

比如 redux-mutli-action 中间件

`/**

  • 支持 dispatch 多个 action dispatch([action1,action2,action3])
  • @param next dispatch

*/
export const reduxMultiAction = next => action => {
if(action){
if (Array.isArray(action)) {
action.map((item) => next(item))
} else {
next(action);
}
}
}`

非常的简单~

然后截止目前编写了两个中间件:

  • 日志打点中间件
  • dispatch 多个 action 中间件
上面的日志打点中间件可能后期会修改。理论上日志的打点不应该都会改变 state,所以是否需要为 ActionLog 提供单独的 reducer,以及提供后如何无缝的衔接,后面做到的时候可能还需要再思考下

模块数据分发

所谓的模块分发,存在的原因是:目前我们的详情页是有很多种不同的业务类型的,单纯的从大资产而言,就分为资产和司法、再分为变卖和拍卖、再有不同类的拍品之区分。也就是说,完整的详情页会有很多的模块,也就是说打开的某一个详情页,并不需要加载所有的模块。这也是为什么下文会有按需加载的 原因。

那么对于数据,我们当然需要根据接口返回的字段,来组织我们的 state 中我们要开发的 component

这里,我们在页面级别的自定义 hooks 文件夹的use-data-init.ts 中操刀。

useDataInit

useDataInit

  • formatCountDownData 是由对应的模块提供的 format 方法。在接口返回的字段需要进行加工的时候需要
  • 此处作为页面级别的 dataInit理论上应该是最全的数据处理情况

format func return

format func return

按需加载

如上所说,不同页面需要不同的模块,目前详情页还未打算接SSR 以及由于组件频繁通信和稳定性要求不能走搭建,所以目前只能通过 codeSpliting 来进行代码分割的按需加载。

是的,通过 useImport

由于是自定义 hooks,所以这里我们不能够通过判断来加载模块不能判断,我怎么知道 if 需要?

事实的确如此。所以我们需要一个容器,来让容器去走判断逻辑~

`interface IWrapperProps{
/**

  • 动态导入的模块 eg:()=>import('xxx')

*/
path:()=>void;
/**

  • 导入的模块所对应的 itemDo 中模块的数据

*/
dataSource:{[key:string]:any};
/**

  • 详情通用字段

*/
detailCommon:IDetailCommon;

}
/**

  • 按需按需加载容器组件

*

  • @export
  • @param {*} props 按需加载的组件 props+path
  • @returns 需按需加载的子组件

*/
export default function(props:IWrapperProps) {
const { path, ...otherProps } = props;
const [Com, error] = useImport(path);
if (Com) {
return <Com {...otherProps} />;
} else if (error) {
console.log(error);
return null;
} else {
return null;
}
}`

可以看到,我会将 DataSource:当前模块数据、以及 detailCommon:通用字段 传递给需要加载的模块中。

然后在 index 中,通过接口是否有该模块字段去判断是否加载:

`const renderCom = (componentConfigArr, itemDo, dispatch) => {
return componentConfigArr.map((item, index) => (

<StoreContext.Provider value={{ itemDo, dispatch }} key={index + 1}>
  <DemandLoadWrapper
    x-if={objHasKeys(itemDo[item.keyName])}
    path={item.importFunc}
    dataSource={itemDo[item.keyName]}
    detailCommon={itemDo?.detailCommon}
  />
</StoreContext.Provider>

));
};`

componentConfigArr来自我们组件 componets/config.ts

`type IComConfigItem<T> = {
keyName: keyof IItemComponent;
importFunc: () => Promise<T>
}
/**

  • 模块的导出配置,用于模块按需加载

*/
export const comConfig: IComConfigItem<Rax.RaxNode>[] = [
{
keyName: 'countDown',
importFunc: () => import('./count-down')
},
{
keyName: "loop",
importFunc: () => import('./loop')
}
];`

keyNameitemDo 中对应接口模块的 key 的名字。这里我们用的 ts 来检查的。

类型约束

类型约束

所以理论上,后续的开发者,新增模块、修改模块,都不应该会修改到index.tsx 这个入口文件

Ts 状态约束

类型约束其实是 TS 的编码应该就塑造的类型思维的一部分 ,毕竟不是介绍 Ts,所以这里主要说下新增模块如何做到类型约束的。

这一块,可能解释起来稍微有点烦

先说下我们的目的是什么:

如上,我们需要在模块 config的配置中读取到组件,并且state 中对应的模块数据注入给这个模块。重点我们还是要根据这个 keyName 来进行按需加载的判断。所以我需要你填写的 keyName 必须是你自己组织(combineReducers)出来 state 对应模块的 key

最终的效果就如上面的截图,编码的时候会提醒你,能够填写哪些字段。那么这个约束是如何形成的呢?

如图,首先我们需要将 combineReducersstate 通过 type 进行约束。当这个约束建立的时候,那么就可以通过这个 type 来进行 config 字段的约束

`/**

  • 标的模块数据

*/
export interface IItemComponent {
/**

  • 倒计时模块

*/
countDown?: IFormattedCountDown;
/**

  • 倒计时模块

*/
loop?: IGetLoopInfo
}
/**

  • 详情页通用字段

*/
export interface IDetailCommon {
/**

  • 标的 id

*/
itemId?: string;
/**

  • 标的类型

*/
itemType?: string;
}
/**

  • detailReducer 返回类型

*/
export interface ICombineItemDo extends IItemComponent{
detailCommon:IDetailCommon
}`

如上的ICombineItemDo就是我们需要拿去约束每一个组件的 reducerdetail.reducer 中汇总出来的state

`export const detailReducer = combineReducers<ICombineItemDo>({
countDown,
loop,
detailCommon: globalStateReducer,
});`

当我们 key 写错了以后,Ts 会帮我们检查出来:

当这个 type 已经拆分重组成我们想要的了时候,那么我们只需要将 configkeyName 约束成 itemDocomponets 的某一个 key 即可。

`type IComConfigItem<T> = {
keyName: keyof IItemComponent;
importFunc: () => Promise<T>
}`

开发契约

所谓的开发契约其实就是你不要瞎 xx 搞~然后给在这个项目中开发的同学提供的一些职业道德约束。当然,程序猿的职业素养也都是不可靠的。所以后续考虑用脚本强制起来~

  • 充分使用 TS 注释即文档的功能,每一个方法、属性、都需要编写对应注释
  • 模块界限清晰,业务逻辑边界分明。不要将非此模块的代码写到公共场所里面。
  • 编写对应 function 的单元测试(有点难)
  • any 大法好,但是不安全

新增模块步骤

上面的契约其实有些泛泛而谈,不如实操来的痛快。下面我们通过举例说明在这个架构下,新增一个模块需要的步骤吧。

1、新增类型

新增数据类型一定是第一步!!! 避免一些低级错误的发生。同时,不是第一步的话。。。你后面的步骤编辑器都会报错的。

拿倒计时举例:

  • 第一步在types/count-down.d.ts 中编写对应模块的类型约束

  • 第二步,在 types/item-dao.d.ts 中注入

`/**

  • 标的模块数据

*/
export interface IItemComponent {

  • /**
    • 倒计时模块
  • */
  • countDown?: IFormattedCountDown;
    /**

    • 倒计时模块

*/
loop?: IGetLoopInfo
}`

最好呢,在 type/index.d.ts 中,统一导出。避免模块引入太多依赖而看起来吓唬人

2、reducer

编写 reducer 也分为两步:

  • 第一步:编写对应 reducer,上文已经介绍到了。
  • 第二步:在detailreducer 中注入进去。

3、模块编写与配置

模块的编写与配置也分为两步:

  • 第一步:在 componets 目录下新建对应模块,编码
  • componets/config.ts中注入

虽然新增一个步骤大致有些繁琐。但是也都中规中矩。每一步分为本身模块的编写以及提供给你的注入方式

TODO

如上所介绍,再结合之前写的前端架构文章,基本上感觉介绍的差不多了。其实前端架构感觉应该换个名字:目录组织。

而搭建的这套组织形式造成的约束其实也是为了提供更好的稳定性保障代码的充分解耦

现在做的远远不够:

  • 项目脚手架
  • 自动化测试
  • 编码规则静态检查
  • 状态可视化
  • 性能优化
  • 代码覆盖率
  • ...

最后,还是那句话,此处权且抛个砖,如果你有更好的见解和想法,欢迎随时交流~

学习交流

  • 关注公众号【全栈前端精选】,每日获取好文推荐
  • 添加微信号:is_Nealyang(备注来源) ,入群交流

公众号【全栈前端精选】

个人微信【is_Nealyang】

本文使用 mdnice 排版

查看原文

赞 0 收藏 0 评论 0

isNealyang 发布了文章 · 4月29日

【THE LAST TIME】从 Redux 源码中学习它的范式

THE LAST TIME

The last time, I have learned

【THE LAST TIME】 一直是我想写的一个系列,旨在厚积薄发,重温前端。

也是给自己的查缺补漏和技术分享。

笔者文章集合详见

TLT往期

前言

范式概念是库恩范式理论的核心,而范式从本质上讲是一种理论体系。库恩指出:按既定的用法,范式就是一种公认的模型或模式

而学习 Redux,也并非它的源码有多么复杂,而是他状态管理的思想,着实值得我们学习。

讲真,标题真的是不好取,因为本文是我写的 redux 的下一篇。两篇凑到一起,才是完整的 Redux

上篇:从 Redux 设计理念到源码分析

本文续上篇,接着看 combineReducersapplyMiddlewarecompose 的设计与源码实现

至于手写,其实也是非常简单,说白了,去掉源码中严谨的校验,就是市面上手写了。当然,本文,我也尽量以手写演进的形式,去展开剩下几个 api 的写法介绍。

combineReducers

从上一篇中我们知道,newState 是在 dispatch 的函数中,通过 currentReducer(currentState,action)拿到的。所以 state 的最终组织的样子,完全的依赖于我们传入的 reducer。而随着应用的不断扩大,state 愈发复杂,redux 就想到了分而治之(我寄几想的词儿)。虽然最终还是一个根,但是每一个枝放到不同的文件 or func 中处理,然后再来组织合并。(模块化有么有)

combineReducers 并不是 redux 的核心,或者说这是一个辅助函数而已。但是我个人还是喜欢这个功能的。它的作用就是把一个由多个不同 reducer 函数作为 valueobject,合并成一个最终的 reducer 函数。

进化过程

比如我们现在需要管理这么一个"庞大"的 state

庞大的 state

let state={
    name:'Nealyang',
    baseInfo:{
        age:'25',
        gender:'man'
    },
    other:{
        github:'https://github.com/Nealyang',
        WeChatOfficialAccount:'全栈前端精选'
    }
}

因为太庞大了,写到一个 reducer 里面去维护太难了。所以我拆分成三个 reducer

function nameReducer(state, action) {
  switch (action.type) {
    case "UPDATE":
      return action.name;
    default:
      return state;
  }
}

function baseInfoReducer(state, action) {
  switch (action.type) {
    case "UPDATE_AGE":
      return {
        ...state,
        age: action.age,
      };
    case "UPDATE_GENDER":
      return {
        ...state,
        age: action.gender,
      };

    default:
      return state;
  }
}


function otherReducer(state,action){...}

为了他这个组成一个我们上文看到的 reducer,我们需要搞个这个函数

const reducer = combineReducers({
  name:nameReducer,
  baseInfo:baseInfoReducer,
  other:otherReducer
})

所以,我们现在自己写一个 combineReducers

function combineReducers(reducers){
    const reducerKeys = Object.keys(reducers);

    return function (state={},action){
        const nextState = {};

        for(let i = 0,keyLen = reducerKeys.length;i<keyLen;i++){
            // 拿出 reducers 的 key,也就是 name、baseInfo、other
            const key = reducerKeys[i];
            // 拿出如上的对应的 reducer: nameReducer、baseInfoReducer、otherReducer
            const reducer = reducers[key];
            // 去除需要传递给对应 reducer 的初始 state
            const preStateKey = state[key];
            // 拿到对应 reducer 处理后的 state
            const nextStateKey = reducer(preStateKey,action);
            // 赋值给新 state 的对应的 key 下面
            nextState[key] = nextStateKey;
        }
        return nextState;
    }
}

基本如上,我们就完事了。

关于 reducer 更多的组合、拆分、使用的,可以参照我 github 开源的前后端博客的 Demo:React-Express-Blog-Demo

源码

export type Reducer<S = any, A extends Action = AnyAction> = (
  state: S | undefined,
  action: A
) => S

export type ReducersMapObject<S = any, A extends Action = Action> = {
  [K in keyof S]: Reducer<S[K], A>
}

定义了一个需要传递给 combineReducers 函数的参数类型。也就是我们上面的

{
  name:nameReducer,
  baseInfo:baseInfoReducer,
  other:otherReducer
}

其实就是变了一个 statekey,然后 key 对应的值是这个 Reducer,这个 Reducerstate 是前面取出这个 keystate 下的值。


export default function combineReducers(reducers: ReducersMapObject) {
  //获取所有的 key,也就是未来 state 的 key,同时也是此时 reducer 对应的 key
  const reducerKeys = Object.keys(reducers)
  // 过滤一遍 reducers 对应的 reducer 确保 kv 格式么有什么毛病
  const finalReducers: ReducersMapObject = {}
  for (let i = 0; i < reducerKeys.length; i++) {
    const key = reducerKeys[i]
    
    if (process.env.NODE_ENV !== 'production') {
      if (typeof reducers[key] === 'undefined') {
        warning(`No reducer provided for key "${key}"`)
      }
    }

    if (typeof reducers[key] === 'function') {
      finalReducers[key] = reducers[key]
    }
  }
  // 再次拿到确切的 keyArray
  const finalReducerKeys = Object.keys(finalReducers)

  // This is used to make sure we don't warn about the same
  // keys multiple times.
  let unexpectedKeyCache: { [key: string]: true }
  if (process.env.NODE_ENV !== 'production') {
    unexpectedKeyCache = {}
  }

  let shapeAssertionError: Error
  try {
    // 校验自定义的 reducer 一些基本的写法
    assertReducerShape(finalReducers)
  } catch (e) {
    shapeAssertionError = e
  }
  // 重点是这个函数
  return function combination(
    state: StateFromReducersMapObject<typeof reducers> = {},
    action: AnyAction
  ) {
    if (shapeAssertionError) {
      throw shapeAssertionError
    }

    if (process.env.NODE_ENV !== 'production') {
      const warningMessage = getUnexpectedStateShapeWarningMessage(
        state,
        finalReducers,
        action,
        unexpectedKeyCache
      )
      if (warningMessage) {
        warning(warningMessage)
      }
    }
    
    let hasChanged = false
    const nextState: StateFromReducersMapObject<typeof reducers> = {}
    for (let i = 0; i < finalReducerKeys.length; i++) {
      const key = finalReducerKeys[i]
      const reducer = finalReducers[key]
      const previousStateForKey = state[key]
      const nextStateForKey = reducer(previousStateForKey, action)
      // 上面的部分都是我们之前手写内容,nextStateForKey 是返回的一个newState,判断不能为 undefined
      if (typeof nextStateForKey === 'undefined') {
        const errorMessage = getUndefinedStateErrorMessage(key, action)
        throw new Error(errorMessage)
      }
      nextState[key] = nextStateForKey
      // 判断是否改变,这里其实我还是很疑惑
      // 理论上,reducer 后的 newState 无论怎么样,都不会等于 preState 的
      hasChanged = hasChanged || nextStateForKey !== previousStateForKey
    }
    hasChanged =
      hasChanged || finalReducerKeys.length !== Object.keys(state).length
    return hasChanged ? nextState : state
  }
}

combineReducers 代码其实非常简单,核心代码也就是我们上面缩写的那样。但是我是真的喜欢这个功能。

applyMiddleware

applyMiddleware 这个方法,其实不得不说,redux 中的 Middleware。中间件的概念不是 redux 独有的。ExpressKoa等框架,也都有这个概念。只是为解决不同的问题而存在罢了。

ReduxMiddleware 说白了就是对 dispatch 的扩展,或者说重写,增强 dispatch 的功能! 一般我们常用的可以记录日志、错误采集、异步调用等。

其实关于ReduxMiddleware, 我觉得中文文档说的就已经非常棒了,这里我简单介绍下。感兴趣的可以查看详细的介绍:Redux 中文文档

Middleware 演化过程

记录日志的功能增强

  • 需求:在每次修改 state 的时候,记录下来 修改前的 state ,为什么修改了,以及修改后的 state
  • Action:每次修改都是 dispatch 发起的,所以这里我只要在 dispatch 加一层处理就一劳永逸了。
const store = createStore(reducer);
const next = store.dispatch;

/*重写了store.dispatch*/
store.dispatch = (action) => {
  console.log('this state', store.getState());
  console.log('action', action);
  next(action);
  console.log('next state', store.getState());
}

如上,在我们每一次修改 dispatch 的时候都可以记录下来日志。因为我们是重写了 dispatch 不是。

增加个错误监控的增强

const store = createStore(reducer);
const next = store.dispatch;

store.dispatch = (action) => {
  try {
    next(action);
  } catch (err) {
    console.error('错误报告: ', err)
  }
}

所以如上,我们也完成了这个需求。

但是,回头看看,这两个需求如何才能够同时实现,并且能够很好地解耦呢?

想一想,既然我们是增强 dispatch。那么是不是我们可以将 dispatch 作为形参传入到我们增强函数。

多文件增强

const exceptionMiddleware = (next) => (action) => {
  try {
    /*loggerMiddleware(action);*/
    next(action);
  } catch (err) {
    console.error('错误报告: ', err)
  } 
}
/*loggerMiddleware 变成参数传进去*/
store.dispatch = exceptionMiddleware(loggerMiddleware);
// 这里额 next 就是最纯的 store.dispatch 了
const loggerMiddleware = (next) => (action) => {
  console.log('this state', store.getState());
  console.log('action', action);
  next(action);
  console.log('next state', store.getState());
}

所以最终使用的时候就如下了

const store = createStore(reducer);
const next = store.dispatch;

const loggerMiddleware = (next) => (action) => {
  console.log('this state', store.getState());
  console.log('action', action);
  next(action);
  console.log('next state', store.getState());
}

const exceptionMiddleware = (next) => (action) => {
  try {
    next(action);
  } catch (err) {
    console.error('错误报告: ', err)
  }
}

store.dispatch = exceptionMiddleware(loggerMiddleware(next));

但是如上的代码,我们又不能将 Middleware 独立到文件里面去,因为依赖外部的 store。所以我们再把 store 传入进去!

const store = createStore(reducer);
const next  = store.dispatch;

const loggerMiddleware = (store) => (next) => (action) => {
  console.log('this state', store.getState());
  console.log('action', action);
  next(action);
  console.log('next state', store.getState());
}

const exceptionMiddleware = (store) => (next) => (action) => {
  try {
    next(action);
  } catch (err) {
    console.error('错误报告: ', err)
  }
}

const logger = loggerMiddleware(store);
const exception = exceptionMiddleware(store);
store.dispatch = exception(logger(next));

以上其实就是我们写的一个 Middleware,理论上,这么写已经可以满足了。但是!是不是有点不美观呢?且阅读起来非常的不直观呢?

如果我需要在增加个中间件,调用就成为了

store.dispatch = exception(time(logger(action(xxxMid(next)))))

这也就是 applyMiddleware 的作用所在了

我们只需要知道有多少个中间件,然后在内部顺序调用就可以了不是

const newCreateStore = applyMiddleware(exceptionMiddleware, timeMiddleware, loggerMiddleware)(createStore);
const store = newCreateStore(reducer)

手写 applyMiddleware

const applyMiddleware = function (...middlewares) {
  // 重写createStore 方法,其实就是返回一个带有增强版(应用了 Middleware )的 dispatch 的 store
  return function rewriteCreateStoreFunc(oldCreateStore) {
  // 返回一个 createStore 供外部调用
    return function newCreateStore(reducer, initState) {
      // 把原版的 store 先取出来
      const store = oldCreateStore(reducer, initState);
      // const chain = [exception, time, logger] 注意这里已经传给 Middleware store 了,有了第一次调用
      const chain = middlewares.map(middleware => middleware(store));
      // 取出原先的 dispatch
      let dispatch = store.dispatch;
      // 中间件调用时←,但是数组是→。所以 reverse。然后在传入 dispatch 进行第二次调用。最后一个就是 dispatch func 了(回忆 Middleware 是不是三个括号~~~)
      chain.reverse().map(middleware => {
        dispatch = middleware(dispatch);
      });
      store.dispatch = dispatch;
      return store;
    }
  }
}
解释全在代码上了

其实源码里面也是这么个逻辑,但是源码实现更加的优雅。他利用了函数式编程的 compose 方法。在看 applyMiddleware 的源码之前呢,先介绍下 compose 的方法吧。

compose

其实 compose 函数做的事就是把 var a = fn1(fn2(fn3(fn4(x)))) 这种嵌套的调用方式改成 var a = compose(fn1,fn2,fn3,fn4)(x) 的方式调用。

compose的运行结果是一个函数,调用这个函数所传递的参数将会作为compose最后一个参数的参数,从而像'洋葱圈'似的,由内向外,逐步调用。

export default function compose(...funcs: Function[]) {
  if (funcs.length === 0) {
    // infer the argument type so it is usable in inference down the line
    return <T>(arg: T) => arg
  }

  if (funcs.length === 1) {
    return funcs[0]
  }

  return funcs.reduce((a, b) => (...args: any) => a(b(...args)))
}

哦豁!有点蒙有么有~ 函数式编程就是烧脑🤯且直接。所以爱的人非常爱。

compose是函数式编程中常用的一种组合函数的方式。

方法很简单,传入的形参是 func[],如果只有一个,那么直接返回调用结果。如果是多个,则funcs.reduce((a, b) => (...args: any) => a(b(...args))).

我们直接啃最后一行吧

import {componse} from 'redux'
function add1(str) {
    return 1 + str;
}
function add2(str) {
    return 2 + str;
}
function add3(a, b) {
    return a + b;
}
let str = compose(add1,add2,add3)('x','y')
console.log(str)
//输出结果 '12xy'

输出

dispatch = compose<typeof dispatch>(...chain)(store.dispatch) applyMiddleware 的源码最后一行是这个。其实即使我们上面手写的 reverse 部分。

reduce 是 es5 的数组方法了,对累加器和数组中的每个元素(从左到右)应用一个函数,将其减少为单个值。函数签名为:arr.reduce(callback[, initialValue])

所以如若我们这么看:

[func1,func2,func3].reduce(function(a,b){
  return function(...args){
    return a(b(...args))
  }
})

所以其实就非常好理解了,每一次 reduce 的时候,callbacka,就是一个a(b(...args))function,当然,第一次是 afunc1。后面就是无限的叠罗汉了。最终拿到的是一个 func1(func2(func3(...args)))function

总结

所以回头看看,redux 其实就这么些东西,第一篇算是 redux 的核心,关于状态管理的思想和方式。第二篇可以理解为 redux 的自带的一些小生态。全部的代码不过两三百行。但是这种状态管理的范式,还是非常指的我们再去思考、借鉴和学习的。

学习交流

  • 关注公众号【全栈前端精选】,每日获取好文推荐
  • 添加微信号:is_Nealyang(备注来源) ,入群交流
公众号【全栈前端精选】个人微信【is_Nealyang】
查看原文

赞 0 收藏 0 评论 0

isNealyang 发布了文章 · 4月24日

从 Redux 设计理念到源码分析

前言

Redux 也是我列在 THE LAST TIME 系列中的一篇,由于现在正在着手探究关于我目前正在开发的业务中状态管理的方案。所以,这里打算先从 Redux 中学习学习,从他的状态中取取经。毕竟,成功总是需要站在巨人的肩膀上不是。

话说回来,都 2020 年了还在写 Redux 的文章,真的是有些过时了。不过呢,当时 Redux 孵化过程中一定也是回头看了 FluxCQRSES 等。

本篇先从 Redux 的设计理念到部分源码分析。下一篇我们在注重说下 ReduxMiddleware工作机制。至于手写,推荐砖家大佬的:完全理解 redux(从零实现一个 redux)

Redux

Redux 并不是什么特别 Giao 的技术,但是其理念真的提的特别好。

说透了,它就是一个提供了 settergetter 的大闭包,。外加一个 pubSub。。。另外的什么 reducermiddleware 还是 action什么的,都是基于他的规则和解决用户使用痛点而来的,仅此而已。下面我们一点点说。。。

设计思想

在 jQuery 时代的时候,我们是面向过程开发,随着 react 的普及,我们提出了状态驱动 UI 的开发模式。我们认为: Web 应用就是状态与 UI 一一对应的关系

但是随着我们的 web 应用日趋的复杂化,一个应用所对应的背后的 state 也变的越来越难以管理。

Redux 就是我们 Web 应用的一个状态管理方案

一一对应

一一对应

如上图所示,store 就是 Redux 提供的一个状态容器。里面存储着 View 层所需要的所有的状态(state)。每一个 UI 都对应着背后的一个状态。Redux 也同样规定。一个 state 就对应一个 View。只要 state 相同,View 就相同。(其实就是 state 驱动 UI)。

为什么要使用 Redux

如上所说,我们现在是状态驱动 UI,那么为什么需要 Redux 来管理状态呢?react 本身就是 state drive view 不是。

原因还是由于现在的前端的地位已经愈发的不一样啦,前端的复杂性也是越来越高。通常一个前端应用都存在大量复杂、无规律的交互。还伴随着各种异步操作。

任何一个操作都可能会改变 state,那么就会导致我们应用的 state 越来越乱,且被动原因愈发的模糊。我们很容易就对这些状态何时发生、为什么发生、怎么发生而失去控制。

如上,如果我们的页面足够复杂,那么view 背后state 的变化就可能呈现出这个样子。不同的 component 之间存在着父子、兄弟、子父、甚至跨层级之间的通信。

而我们理想中的状态管理应该是这个样子的:

单纯的从架构层面而言,UI 与状态完全分离,并且单向的数据流确保了状态可控。

Redux 就是做这个的!

  • 每一个 State 的变化可预测
  • 动作和状态统一管理

下面简单介绍下 Redux 中的几个概念。其实初学者往往就是对其概念而困惑。

store

保存数据的地方,你可以把它看成一个容器,整个应用只能有一个Store

State

某一个时刻,存储着的应用状态值

Action

View 发出的一种让 state 发生变化的通知

Action Creator

可以理解为 Action 的工厂函数

dispatch

View 发出 Action 的媒介。也是唯一途径

reducer

根据当前接收到的ActionState,整合出来一个全新的 State。注意是需要是纯函数

三大原则

Redux 的使用,基于以下三个原则

单一数据源

单一数据源这或许是与 Flux 最大的不同了。在 Redux 中,整个应用的 state 都被存储到一个object 中。当然,这也是唯一存储应用状态的地方。我们可以理解为就是一个 Object tree。不同的枝干对应不同的 Component。但是归根结底只有一个根。

也是受益于单一的 state tree。以前难以实现的“撤销/重做”甚至回放。都变得轻松了很多。

State 只读

唯一改变 state 的方法就是 dispatch 一个 actionaction 就是一个令牌而已。normal Object

任何 state 的变更,都可以理解为非 View 层引起的(网络请求、用户点击等)。View 层只是发出了某一中意图。而如何去满足,完全取决于 Redux 本身,也就是 reducer。

`store.dispatch({
type:'FETCH_START',
params:{

itemId:233333

}
})`

使用纯函数来修改

所谓纯函数,就是你得纯,别变来变去了。书面词汇这里就不做过多解释了。而这里我们说的纯函数来修改,其实就是我们上面说的 reducer

Reducer 就是纯函数,它接受当前的 stateaction。然后返回一个新的 state。所以这里,state 不会更新,只会替换。

之所以要纯函数,就是结果可预测性。只要传入的 stateaction 一直,那么就可以理解为返回的新 state 也总是一样的。

总结

Redux 的东西远不止上面说的那么些。其实还有比如 middleware、actionCreator 等等等。其实都是使用过程中的衍生品而已。我们主要是理解其思想。然后再去源码中学习如何使用。

源码分析

Redux 源码本身非常简单,限于篇幅,我们下一篇再去介绍composecombineReducersapplyMiddleware

目录结构

目录结构

Redux 源码本身就是很简单,代码量也不大。学习它,也主要是为了学习他的编程思想和设计范式。

当然,我们也可以从 Redux 的代码里,看看大佬是如何使用 ts 的。所以源码分析里面,我们还回去花费不少精力看下 Redux 的类型说明。所以我们从 type 开始看

src/types

看类型声明也是为了学习Redux 的 ts 类型声明写法。所以相似声明的写法形式我们就不重复介绍了。

actions.ts

类型声明也没有太多的需要去说的逻辑,所以我就写注释上吧

`// Action的接口定义。type 字段明确声明
export interface Action<T = any> {
  type: T
}
export interface AnyAction extends Action {
  // 在 Action 的这个接口上额外扩展的另外一些任意字段(我们一般写的都是 AnyAction 类型,用一个“基类”去约束必须带有 type 字段)
  [extraProps: string]: any
}
export interface ActionCreator {
  // 函数接口,泛型约束函数的返回都是 A
  (...args: any[]): A
}
export interface ActionCreatorsMapObject<A = any> {
  // 对象,对象值为 ActionCreator
  [key: string]: ActionCreator

}
`

reducers.ts

`// 定义的一个函数,接受 S 和继承 Action 默认为 AnyAction 的 A,返回 S
export type Reducer<S = any, A extends Action = AnyAction> = (
  state: S | undefined,
  action: A
) => S

// 可以理解为 S 的 key 作为ReducersMapObject的 key,然后 value 是  Reducer的函数。in 我们可以理解为遍历
export type ReducersMapObject<S = any, A extends Action = Action> = {
  [K in keyof S]: Reducer<S[K], A>
}
`

上面两个声明比较简单直接。下面两个稍微麻烦一些

`export type StateFromReducersMapObject<M> = M extends ReducersMapObject<
  any,
  any

  ? { [P in keyof M]: M[P] extends Reducer<infer S, any> ? S : never }
  : never
  
export type ReducerFromReducersMapObject<M> = M extends {
  [P in keyof M]: infer R
}
  ? R extends Reducer<any, any>
    ? R
    : never
  : never
`

上面两个声明,咱们来解释其中第一个吧(稍微麻烦些)。

  • StateFromReducersMapObject 添加另一个泛型M约束
  • M 如果继承 ReducersMapObject<any,any>则走{ [P in keyof M]: M[P] extends Reducer<infer S, any> ? S : never }的逻辑
  • 否则就是 never。啥也不是
  • { [P in keyof M]: M[P] extends Reducer<infer S, any> ? S : never } 很明显,这就是一个对象,key 来自 M 对象里面,也就是ReducersMapObject里面传入的Skey 对应的 value 就是需要判断 M[P]是否继承自 Reducer。否则也啥也不是
  • infer 关键字和 extends 一直配合使用。这里就是指返回 Reducer 的这个 State的类型

其他

types 目录里面其他的比如 storemiddleware都是如上的这种声明方式,就不再赘述了,感兴趣的可以翻阅翻阅。然后取其精华的应用到自己的 ts 项目里面

src/createStore.ts

不要疑惑上面函数重载的写法~

不要疑惑上面函数重载的写法~

可以看到,整个createStore.ts 就是一个createStore 函数。

createStore

三个参数:

  • reducer:就是 reducer,根据 action 和 currentState 计算 newState 的纯 Function
  • preloadedState:initial State
  • enhancer:增强store的功能,让它拥有第三方的功能

createStore 里面就是一些闭包函数的功能整合

INIT

`// A extends Action
dispatch({ type: ActionTypes.INIT } as A)
`

这个方法是Redux保留用的,用来初始化State,其实就是dispatch 走到我们默认的 switch case default 的分支里面获取到默认的 State

return

`const store = ({
    dispatch: dispatch as Dispatch,
    subscribe,
    getState,
    replaceReducer,
    [$$observable]: observable
  } as unknown) as Store<ExtendState<S, StateExt>, A, StateExt, Ext> & Ext
`

ts 的类型转换语法就不说了,返回的对象里面包含dispatchsubscribegetStatereplaceReducer[$$observable].

这里我们简单介绍下前三个方法的实现。

getState

``  function getState(): S {
    if (isDispatching) {
      throw new Error(
        我 reducer 正在执行,newState 正在产出呢!现在不行
      )
    }

    return currentState as S
  }
``

方法很简单,就是 return currentState

subscribe

subscribe的作用就是添加监听函数listener,让其在每次dispatch action的时候调用。

返回一个移除这个监听的函数。

使用如下:

`const unsubscribe = store.subscribe(() =>
  console.log(store.getState())
)

unsubscribe();
`

``function subscribe(listener: () => void) {
    // 如果 listenter 不是一个 function,我就报错(其实 ts 静态检查能检查出来的,但!那是编译时,这是运行时)
    if (typeof listener !== 'function') {
      throw new Error('Expected the listener to be a function.')
    }
    // 同 getState 一个样纸
    if (isDispatching) {
      throw new Error(
        'You may not call store.subscribe() while the reducer is executing. ' +
        'If you would like to be notified after the store has been updated, subscribe from a ' +
        'component and invoke store.getState() in the callback to access the latest state. ' +
        'See https://Redux.js.org/api/store#subscribelistener for more details.'
      )
    }

    let isSubscribed = true

    ensureCanMutateNextListeners()
    // 直接将监听的函数放进nextListeners里
    nextListeners.push(listener)

    return function unsubscribe() {// 也是利用闭包,查看是否以订阅,然后移除订阅
      if (!isSubscribed) {
        return
      }

      if (isDispatching) {
        throw new Error(
          'You may not unsubscribe from a store listener while the reducer is executing. ' +
          'See https://Redux.js.org/api/store#subscribelistener for more details.'
        )
      }

      isSubscribed = false//修改这个订阅状态

      ensureCanMutateNextListeners()
      //找到位置,移除监听
      const index = nextListeners.indexOf(listener) 
      nextListeners.splice(index, 1)
      currentListeners = null
    }
  }
``

一句话解释就是在 listeners 数据里面添加一个函数

再来说说这里面的ensureCanMutateNextListeners,很多 Redux 源码都么有怎么提及这个方法的作用。也是让我有点困惑。

这个方法的实现非常简单。就是判断当前的监听数组里面是否和下一个数组相等。如果是!则 copy 一份。

`  let currentListeners: (() => void)[] | null = []
  let nextListeners = currentListeners
  
  function ensureCanMutateNextListeners() {
    if (nextListeners === currentListeners) {
      nextListeners = currentListeners.slice()
    }
  }
`

那么为什么呢?这里留个彩蛋。等看完 dispatch 再来看这个疑惑。

dispatch

`  function dispatch(action: A) {
  // action必须是个普通对象
    if (!isPlainObject(action)) {
      throw new Error(
        'Actions must be plain objects. ' +
        'Use custom middleware for async actions.'
      )
    }
  // 必须包含 type 字段
    if (typeof action.type === 'undefined') {
      throw new Error(
        'Actions may not have an undefined "type" property. ' +
        'Have you misspelled a constant?'
      )
    }
  // 同上
    if (isDispatching) {
      throw new Error('Reducers may not dispatch actions.')
    }

    try {
      // 设置正在 dispatch 的 tag 为 true(解释了那些判断都是从哪里来的了)
      isDispatching = true
      // 通过传入的 reducer 来去的新的 state
      //  let currentReducer = reducer
      currentState = currentReducer(currentState, action)
    } finally {
    // 修改状态
      isDispatching = false
    }
    
    // 将 nextListener 赋值给 currentListeners、listeners (注意回顾 ensureCanMutateNextListeners )
    const listeners = (currentListeners = nextListeners)
    // 挨个触发监听
    for (let i = 0; i < listeners.length; i++) {
      const listener = listeners[i]
      listener()
    }

    return action
  }
`

方法很简单,都写在注释里了。这里我们再回过头来看ensureCanMutateNextListeners的意义

ensureCanMutateNextListeners

`  let currentListeners: (() => void)[] | null = []
  let nextListeners = currentListeners
  
  function ensureCanMutateNextListeners() {
    if (nextListeners === currentListeners) {
      nextListeners = currentListeners.slice()
    }
  }

  function subscribe(listener: () => void) {
    // ...
    ensureCanMutateNextListeners()
    nextListeners.push(listener)

    return function unsubscribe() {
      ensureCanMutateNextListeners()
      const index = nextListeners.indexOf(listener)
      nextListeners.splice(index, 1)
      currentListeners = null
    }
  }
  
  function dispatch(action: A) {
    // ... 
    const listeners = (currentListeners = nextListeners)
    for (let i = 0; i < listeners.length; i++) {
      const listener = listeners[i]
      listener()
    }
    // ...
    return action
  }
`

从上,代码看起来貌似只要一个数组来存储listener 就可以了。但是事实是,我们恰恰就是我们的 listener 是可以被 unSubscribe 的。而且 slice 会改变原数组大小。

所以这里增加了一个 listener 的副本,是为了避免在遍历listeners的过程中由于subscribe或者unsubscribelisteners进行的修改而引起的某个listener被漏掉了。

最后

限于篇幅,就暂时写到这吧~

其实后面打算重点介绍的 Middleware,只是中间件的一种更规范,甚至我们可以理解为,它并不属于 Redux 的。因为到这里,你已经完全可以自己写一份状态管理方案了。

combineReducers也是我认为是费巧妙的设计。所以这些篇幅,就放到下一篇吧~

参考链接

学习交流

  • 关注公众号【全栈前端精选】,每日获取好文推荐
  • 添加微信号:is_Nealyang(备注来源) ,入群交流

公众号【全栈前端精选】

个人微信【is_Nealyang】

本文使用 mdnice 排版

查看原文

赞 3 收藏 1 评论 0

isNealyang 发布了文章 · 3月23日

Typescript 进阶 之 重难点梳理

THE LAST TIME

The last time, I have learned

【THE LAST TIME】 一直是我想写的一个系列,旨在厚积薄发,重温前端。

也是给自己的查缺补漏和技术分享。

笔者文章集合详见

前言

JavaScript 毋庸置疑是一门非常好的语言,但是其也有很多的弊端,其中不乏是作者设计之处留下的一些 “bug”。当然,瑕不掩瑜~

话说回来,JavaScript 毕竟是一门弱类型语言,与强类型语言相比,其最大的编程陋习就是可能会造成我们类型思维的缺失(高级词汇,我从极客时间学到的)。而思维方式决定了编程习惯,编程习惯奠定了工程质量,工程质量划定了能力边界,而学习 Typescript,最重要的就是我们类型思维的重塑。

那么其实,Typescript 在我个人理解,并不能算是一个编程语言,它只是 JavaScript 的一层壳。当然,我们完全可以将它作为一门语言去学习。网上有很多推荐 or 不推荐 Typescript 之类的文章这里我们不做任何讨论,学与不学,用或不用,利与弊。各自拿捏~

再说说 typescript(下文均用 ts 简称),其实对于 ts 相比大家已经不陌生了。更多关于 ts 入门文章和文档也是已经烂大街了。此文不去翻译或者搬运各种 api或者教程章节。只是总结罗列和解惑,笔者在学习 ts 过程中曾疑惑的地方。道不到的地方,欢迎大家评论区积极讨论。

其实 Ts 的入门非常的简单:.js to .ts; over!

但是为什么我都会写 ts 了,却看不懂别人的代码呢? 这!就是入门与进阶之隔。也是本文的目的所在。

首先推荐下 ts 的编译环境:typescriptlang.org

再推荐笔者收藏的几个网站:

下面,逐个难点梳理,逐个击破。

可索引类型

关于ts 的类型应该不用过多介绍了,多用多记 即可。介绍下关于 ts 的可索引类型。准确的说,这应该属于接口的一类范畴。说到接口(interface),我们都知道 ts 的核心原则之一就是对值所具有的结构进行类型检查。 它有时被称之为“鸭式辩型法”或“结构性子类型”。而接口就是其中的契约。可索引类型也是接口的一种表现形式,非常实用!

interface StringArray {
  [index: number]: string;
}

let myArray: StringArray;
myArray = ["Bob", "Fred"];

let myStr: string = myArray[0];

上面例子里,我们定义了StringArray接口,它具有索引签名。 这个索引签名表示了当用number去索引StringArray时会得到string类型的返回值。
Typescript支持两种索引签名:字符串和数字。 可以同时使用两种类型的索引,但是数字索引的返回值必须是字符串索引返回值类型的子类型。

这是因为当使用number来索引时,JavaScript会将它转换成string然后再去索引对象。 也就是说用100(一个number)去索引等同于使用"100"(一个string)去索引,因此两者需要保持一致。

class Animal {
    name: string;
}
class Dog extends Animal {
    breed: string;
}

// 错误:使用数值型的字符串索引,有时会得到完全不同的Animal!
interface NotOkay {
    [x: number]: Animal;
    [x: string]: Dog;
}

下面的例子里,name的类型与字符串索引类型不匹配,所以类型检查器给出一个错误提示:

interface NumberDictionary {
  [index: string]: number;
  length: number;    // 可以,length是number类型
  name: string       // 错误,`name`的类型与索引类型返回值的类型不匹配
}

当然,我们也可以将索引签名设置为只读,这样就可以防止给索引赋值

interface ReadonlyStringArray {
    readonly [index: number]: string;
}
let myArray: ReadonlyStringArray = ["Alice", "Bob"];
myArray[2] = "Mallory"; // error!

interface 和 type 关键字

stackoverflow 上的一个高赞回答还是非常赞的。typescript-interfaces-vs-types

interfacetype 两个关键字的含义和功能都非常的接近。这里我们罗列下这两个主要的区别:

interface

  • 同名的 interface 自动聚合,也可以跟同名的 class 自动聚合
  • 只能表示 objectclassfunction 类型

type:

  • 不仅仅能够表示 objectclassfunction
  • 不能重名(自然不存在同名聚合了),扩展已有的 type 需要创建新 type
  • 支持复杂的类型操作

举例说明下上面罗列的几点:

Objects/Functions

都可以用来表示 Object 或者 Function ,只是语法上有些不同而已

interface Point{
  x:number;
  y:number;
}

interface SetPoint{
  (x:number,y:number):void;
}
type Point = {
  x:number;
  y:number;
}

type SetPoint = (x:number,y:number) => void;

其他数据类型

interface 不同,type 还可以用来标书其他的类型,比如基本数据类型、元素、并集等

type Name = string;

type PartialPointX = {x:number;};
type PartialPointY = {y:number;};

type PartialPoint = PartialPointX | PartialPointY;

type Data = [number,string,boolean];

Extend

都可以被继承,但是语法上会有些不同。另外需要注意的是,interface 和 type 彼此并不互斥

interface extends interface

interface PartialPointX {x:number;};
interface Point extends PartialPointX {y:number;};

type extends type

type PartialPointX = {x:number;};
type Point = PartialPointX & {y:number;};

interface extends type

type PartialPointX = {x:number;};
interface Point extends PartialPointX {y:number;};

type extends interface

interface ParticalPointX = {x:number;};

type Point = ParticalPointX & {y:number};

implements

一个类,可以以完全相同的形式去实现interface 或者 type。但是,类和接口都被视为静态蓝图(static blueprints),因此,他们不能实现/继承 联合类型的 type

interface Point {
  x: number;
  y: number;
}

class SomePoint implements Point {
  x: 1;
  y: 2;
}

type Point2 = {
  x: number;
  y: number;
};

class SomePoint2 implements Point2 {
  x: 1;
  y: 2;
}

type PartialPoint = { x: number; } | { y: number; };

// FIXME: can not implement a union type
class SomePartialPoint implements PartialPoint {
  x: 1;
  y: 2;
}

声明合并

type 不同,interface 可以被重复定义,并且会被自动聚合

interface Point {x:number;};
interface Point {y:number;};

const point:Pint = {x:1,y:2};

only interface can

在实际开发中,有的时候也会遇到 interface 能够表达,但是 type 做不到的情况:给函数挂载属性

interface FuncWithAttachment {
  (param: string): boolean;
  someProperty: number;
}

const testFunc: FuncWithAttachment = function(param: string) {
  return param.indexOf("Neal") > -1;
};
const result = testFunc("Nealyang"); // 有类型提醒
testFunc.someProperty = 4;

& 和 | 操作符

这里我们需要区分,|&并非位运算符。我们可以理解为&表示必须同时满足所有的契约。|表示可以只满足一个契约。

interface IA{
  a:string;
  b:string;
}

type TB{
  b:number;
  c:number [];
}

type TC = TA | TB;// TC 的 key,包含 ab 或者 bc 即可,当然,包含 bac 也可以
type TD = TA & TB;// TD 的 可以,必须包含 abc

交叉类型

交叉类型,我们可以理解为合并。其实就是将多个类型合并为一个类型

Man & WoMan
  • 同时是 Man 和 Woman
  • 同时拥有 Man 和 Woman 这两种类型的成员
interface ObjectConstructor{
  assign<T,U>(target:T,source:U):T & U;
}

以上是 ts 的源码实现,下面我们再看一个我们日常使用中的例子

interface A{
  name:string;
  age:number;
  sayName:(name:string)=>void
}

interface B{
  name:string;
  gender:string;
  sayGender:(gender:string)=>void
}

let a:A&B;

// 这是合法的
a.age
a.sayGender

注意:16446

T & never = never 

extends

extends 即为扩展、继承。在 ts 中,extends 关键字既可以来扩展已有的类型,也可以对类型进行条件限定。在扩展已有类型时,不可以进行类型冲突的覆盖操作。例如,基类型中键astring,在扩展出的类型中无法将其改为number

type num = {
  num:number;
}

interface IStrNum extends num {
  str:string;
}

// 与上面等价
type TStrNum = A & {
  str:string;
}

在 ts 中,我们还可以通过条件类型进行一些三目操作:T extends U ? X : Y

type IsEqualType<A , B> = A extends B ? (B extends A ? true : false) : false;

type NumberEqualsToString = IsEqualType<number,string>; // false
type NumberEqualsToNumber = IsEqualType<number,number>; // true

keyof

keyof 是索引类型操作符。用于获取一个“常量”的类型,这里的“常量”是指任何可以在编译期确定的东西,例如constfunctionclass等。它是从 实际运行代码 通向 类型系统 的单行道。理论上,任何运行时的符号名想要为类型系统所用,都要加上 typeof

在使用class时,class名表示实例类型,typeof class表示 class本身类型。是的,这个关键字和 js 的 typeof 关键字重名了 。

假设 T 是一个类型,那么 keyof T 产生的类型就是 T 的属性名称字符串字面量类型构成的联合类型(联合类型比较简单,和交叉类型对立相似,这里就不做介绍了)。

注意!上述的 T 是数据类型,并非数据本身

interface IQZQD{
    cnName:string;
    age:number;
    author:string;
}
type ant = keyof IQZQD;

vscode 上,我们可以看到 ts 推断出来的 ant

注意,如果 T 是带有字符串索引的类型,那么keyof Tstring或者number类型。

索引签名参数类型必须为 "string" 或 "number"

interface Map<T> {
  [key: string]: T;
}

//T[U]是索引访问操作符;U是一个属性名称。
let keys: keyof Map<number>; //string | number
let value: Map<number>['antzone'];//number

泛型

泛型可能是对于前端同学来说理解起来有点困难的知识点了。通常我们说,泛型就是指定一个表示类型的变量,用它来代替某个实际的类型用于编程,而后再通过实际运行或推导的类型来对其进行替换,以达到一段使用泛型程序可以实际适应不同类型的目的。说白了,泛型就是不预先确定的数据类型,具体的类型在使用的时候再确定的一种类型约束规范

泛型可以应用于 functioninterfacetype 或者 class 中。但是注意,泛型不能应用于类的静态成员

几个简单的例子,先感受下泛型

function log<T>(value: T): T {
    console.log(value);
    return value;
}

// 两种调用方式
log<string[]>(['a', ',b', 'c'])
log(['a', ',b', 'c'])
log('Nealyang')
  • 泛型类型、泛型接口
type Log = <T>(value: T) => T
let myLog: Log = log

interface Log<T> {
    (value: T): T
}
let myLog: Log<number> = log // 泛型约束了整个接口,实现的时候必须指定类型。如果不指定类型,就在定义的之后指定一个默认的类型
myLog(1)

我们也可以把泛型变量理解为函数的参数,只不过是另一个维度的参数,是代表类型而不是代表值的参数。

class Log<T> { // 泛型不能应用于类的静态成员
    run(value: T) {
        console.log(value)
        return value
    }
}

let log1 = new Log<number>() //实例化的时候可以显示的传入泛型的类型
log1.run(1)
let log2 = new Log()
log2.run({ a: 1 }) //也可以不传入类型参数,当不指定的时候,value 的值就可以是任意的值

类型约束,需预定义一个接口

interface Length {
    length: number
}
function logAdvance<T extends Length>(value: T): T {
    console.log(value, value.length);
    return value;
}

// 输入的参数不管是什么类型,都必须具有 length 属性
logAdvance([1])
logAdvance('123')
logAdvance({ length: 3 })

泛型的好处:

  • 函数和类可以轻松的支持多种类型,增强程序的扩展性
  • 不必写多条函数重载,冗长的联合类型声明,增强代码的可读性
  • 灵活控制类型之间的约束

泛型,在 ts 内部也都是非常常用的,尤其是对于容器类非常常用。而对于我们,还是要多使用,多思考的,这样才会有更加深刻的体会。同时也对塑造我们类型思维非常的有帮助。

小试牛刀

function pluck<T, K extends keyof T>(o: T, names: K[]): T[K][] {
    return names.map(n => o[n]);
}

interface Person {
    name: string;
    age: number;
}

let person: Person = {
    name: 'Jarid',
    age: 35
};
let strings: string[] = pluck(person, ['name', 'name', 'name']); //["Jarid", "Jarid", "Jarid"]

所谓的小试牛刀,就是结合上面我们说的那几个点,分析下pluck方法的意思

  • <T, K extends keyof T>约束了这是一个泛型函数

    • keyof T 就是取 T 中的所有的常量 key(这个例子的调用中),即为: "name" | "age"
    • K extends keyof Person 即为 K 是 "name" or "age"
  • 结合以上泛型解释,再看形参

    • K[] 即为 只能包含"name" or "age"的数组
  • 再看返回值

    • T[K][] 后面的[]是数组的意思。而 T[K]就是去对象的 T 下的key: Kvalue

infer

infer 关键字最早出现在 PR 里面,表示在 extends 条件语句中待推断的类型变量

是在 ts2.8 引入的,在条件判断语句中,该关键字用于替换手动获取类型

type PramType<T> = T extends (param : infer p) => any ? p : T;

在上面的条件语句中,infer P 表示待推断的函数参数,如果T能赋值给(param : infer p) => any,则结果是(param: infer P) => any类型中的参数 P,否则为T.

interface INealyang{
  name:'Nealyang';
  age:'25';
}

type Func = (user:INealyang) => void;

type Param = ParamType<Func>; // Param = INealyang
type Test = ParamType<string>; // string

工具泛型

所谓的工具泛型,其实就是泛型的一些语法糖的实现。完全也是可以自己的写的。我们也可以在lib.d.ts中找到他们的定义

Partial

Partial的作用就是将传入的属性变为可选。

由于 keyof 关键字已经介绍了。其实就是可以用来取得一个对象接口的所有 key 值。在介绍 Partial 之前,我们再介绍下 in 操作符:

type Keys = "a" | "b"
type Obj =  {
  [p in Keys]: any
} // -> { a: any, b: any }

然后再看 Partial 的实现:

type Partial<T> = { [P in keyof T]?: T[P] };

翻译一下就是keyof T 拿到 T 所有属性名, 然后 in 进行遍历, 将值赋给 P, 最后 T[P] 取得相应属性的值,然后配合?:改为可选。

Required

Required 的作用是将传入的属性变为必选项, 源码如下

type Required<T> = { [P in keyof T]-?: T[P] };

Readonly

将传入的属性变为只读选项, 源码如下

type Readonly<T> = { readonly [P in keyof T]: T[P] };

Record

该类型可以将 K 中所有的属性的值转化为 T 类型,源码实现如下:

/**
 * Construct a type with a set of properties K of type T
 */
type Record<K extends keyof any, T> = {
    [P in K]: T;
};

可以根据 K 中的所有可能值来设置 key,以及 value 的类型,举个例子:

type T11 = Record<'a' | 'b' | 'c', Person>; // -> { a: Person; b: Person; c: Person; }

Pick

T 中取出 一系列 K 的属性

/**
 * From T, pick a set of properties whose keys are in the union K
 */
type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};

Exclude

Exclude 将某个类型中属于另一个的类型移除掉。

/**
 * Exclude from T those types that are assignable to U
 */
type Exclude<T, U> = T extends U ? never : T;

以上语句的意思就是 如果 T 能赋值给 U 类型的话,那么就会返回 never 类型,否则返回 T,最终结果是将 T 中的某些属于 U 的类型移除掉

举个栗子:

type T00 = Exclude<'a' | 'b' | 'c' | 'd', 'a' | 'c' | 'f'>;  // -> 'b' | 'd'

可以看到 T'a' | 'b' | 'c' | 'd' ,然后 U'a' | 'c' | 'f' ,返回的新类型就可以将 U 中的类型给移除掉,也就是 'b' | 'd' 了。

Extract

Extract 的作用是提取出 T 包含在 U 中的元素,换种更加贴近语义的说法就是从 T 中提取出 U,源码如下:

/**
 * Extract from T those types that are assignable to U
 */
type Extract<T, U> = T extends U ? T : never;

Demo:

type T01 = Extract<'a' | 'b' | 'c' | 'd', 'a' | 'c' | 'f'>;  // -> 'a' | 'c'

Omit

PickExclude 进行组合, 实现忽略对象某些属性功能, 源码如下:

/**
 * Construct a type with the properties of T except for those in type K.
 */
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

Demo:

// 使用
type Foo = Omit<{name: string, age: number}, 'name'> // -> { age: number }

更多工具泛型

其实常用的工具泛型大概就是我上面介绍的几种。更多的工具泛型,可以通过查看 lib.es5.d.ts里面查看。

毕竟。。。搬运几段声明着实没啥意思。

罗列 api 的写着也怪无聊的...

类型断言

断言这种东西还是少用。。。。不多对于初学者,估计最快熟练掌握的就是类型断言了。毕竟 any 大法好

Typescript 允许我们覆盖它的推断(毕竟代码使我们自己写的),然后根据我们自定义的类型去分析它。这种机制,我们称之为 类型断言

const nealyang = {};
nealyang.enName = 'Nealyang'; // Error: 'enName' 属性不存在于 ‘{}’
nealyang.cnName = '一凨'; // Error: 'cnName' 属性不存在于 '{}'
interface INealyang = {
  enName:string;
  cnName:string;
}

const nealyang = {} as INealyang; // const nealyang = <INealyang>{};
nealyang.enName = 'Nealyang';
nealyang.cnName = '一凨'; 

类型断言比较简单,其实就是“纠正”ts对类型的判断,当然,是不是纠正就看你自己的了。

需要注意一下两点即可:

  • 推荐类型断言的预发使用 as关键字,而不是<> ,防止歧义
  • 类型断言并非类型转换,类型断言发生在编译阶段。类型转换发生在运行时

函数重载

在我刚开始使用 ts 的时候,我一直困惑。。。为什么会有函数重载这么鸡肋的写法,可选参数它不香么?

慢慢你品

函数重载的基本语法:

declare function test(a: number): number;
declare function test(a: string): string;

const resS = test('Hello World');  // resS 被推断出类型为 string;
const resN = test(1234);           // resN 被推断出类型为 number;

这里我们申明了两次?!为什么我不能判断类型或者可选参数呢?后来我遇到这么一个场景,

interface User {
  name: string;
  age: number;
}

declare function test(para: User | number, flag?: boolean): number;

在这个 test 函数里,我们的本意可能是当传入参数 para 是 User 时,不传 flag,当传入 para 是 number 时,传入 flag。TypeScript 并不知道这些,当你传入 para 为 User 时,flag 同样允许你传入:

const user = {
  name: 'Jack',
  age: 666
}

// 没有报错,但是与想法违背
const res = test(user, false);

使用函数重载能帮助我们实现:

interface User {
  name: string;
  age: number;
}

declare function test(para: User): number;
declare function test(para: number, flag: boolean): number;

const user = {
  name: 'Jack',
  age: 666
};

// bingo
// Error: 参数不匹配
const res = test(user, false);

Ts 的一些实战

我之前在公众号里面发表过两篇关于TS在实战项目中的介绍:

参考文献

学习交流

  • 关注公众号【全栈前端精选】,每日获取好文推荐
  • 添加微信号:is_Nealyang(备注来源) ,入群交流
公众号【全栈前端精选】个人微信【is_Nealyang】
查看原文

赞 4 收藏 3 评论 0

isNealyang 发布了文章 · 3月17日

一张页面引起的项目架构思考(Rax+Typescript+hooks)

前言

好的书本分章节、好的代码分模块,那么好的架构该如何定义呢?

咳咳,不要意思,题目起大了~~ 小生之辈,岂敢以架构而论。

不过话说来,很多人都认为前端无非就是 HTML+CSS+JS,一个目录一类文件,有何架构可言。但是我想说。。。。你说的都对!

但是,笔者一直在探索不同的页面架构组织形式,鄙人愚见,好的架构,能够方便拓展和开发以及后期的项目维护。

在笔者刚开始接触前端的时候,就一直在思考怎么样的架构比较舒服易于扩展,且能装 B。React-Full-Dianping-Demo里面就有写到对于react+react-redux+soga的一些列代码组织的思考:react技术栈项目结构探究

一直还在学习,本文也只是拿来探讨下本次我开发一个页面时,我个人的一些代码组织方式。抛个砖~

望各位大佬不啬赐教。

项目架构

src
├─ action-log
│    ├─ constants.ts
│    └─ index.ts
├─ app.js
├─ app.json
├─ common
│    ├─ animation-utils.ts
│    ├─ business-utils.ts
│    ├─ constants.ts
│    ├─ detail-utils.ts
│    ├─ mtop-utils.ts
│    ├─ net-utils.ts
│    ├─ price-utils.ts
│    ├─ storage-utils.ts
│    ├─ string-utils.ts
│    ├─ time-utils.ts
│    ├─ type.ts
│    ├─ url-utils.ts
│    └─ utils.ts
├─ components
│    ├─ loading-page
│    │    ├─ index.css
│    │    └─ index.tsx
│    └─ pm-bottom
│           ├─ index.css
│           └─ index.tsx
├─ document
│    └─ index.jsx
├─ event
│    └─ EVENTS.ts
├─ modules
│    ├─ bottom-action
│    │    ├─ index.css
│    │    └─ index.tsx
│    └─ page-container
│           ├─ base
│           ├─ decorator
│           ├─ index.tsx
│           └─ libs
└─ pages
       ├─ buyer-identity
       │    ├─ components
       │    ├─ constants
       │    ├─ customized-hooks
       │    ├─ index.tsx
       │    ├─ types
       │    └─ utils

或许上面看起来并不是很直观,截图解释下

大概的看下,脑海中有个大概的位置和每个文件的作用。下面我们再来细品

目录职责

其实划分了这么多的目录,无非就是为了最大可能的复用。其中也包括对于组件状态的抽离、hooks 特性的利用

pages 层以外的公共逻辑

毕竟是MPA应用,所以一切还都是围绕着 pages 展开。

action-log

首先这里的action-log目录就不多说了,因为没有太多可借鉴性。大概就是返回一个 ActionLog对象,来进行一些业务上的埋点、信息收集等逻辑的处理。所以这里如果大家有一些公共的基础类封装,都是可以放这里的。

common

common
├─ animation-utils.ts
├─ business-utils.ts
├─ constants.ts
├─ detail-utils.ts
├─ mtop-utils.ts
├─ net-utils.ts
├─ price-utils.ts
├─ storage-utils.ts
├─ string-utils.ts
├─ time-utils.ts
├─ type.ts
├─ url-utils.ts
└─ utils.ts

由于该项目的比较复杂,业务逻辑相对较多。所以这里我将 utils按照类别,区分出来了以上几种。方面后期开发中的维护和扩展,也便于查找。

除了一些从命名可以区分出来的utils 以外,这里还放了一个 type.tsconstants.ts,用途自如其名。

components

相信框架使用者对于 components 的命名都不为陌生.是的,就是对于一些公共组件的封装,比如我这里放的两个组件loading-page,pm-bottom等公共组件。components 相对来说是比较“小”的概念,划分依据这这个项目中也比较简单,就是是否为“木偶组件”(虽然 hooks 了以后,咱不太适合这么说),

modules

modules
├─ bottom-action
│    ├─ index.css
│    └─ index.tsx
└─ page-container
       ├─ base
       │    ├─ base.tsx
       │    ├─ error.tsx
       │    └─ scrollBase.tsx
       ├─ decorator
       │    └─ withError.tsx
       ├─ index.tsx
       └─ libs
              ├─ displayName.ts
              ├─ navbarTransparent.ts
              ├─ spm.ts
              └─ title.ts

更具有模块的概念,这里最典型的page-contaienr的模块,作用就是每一个页面的通用底层容器,早在之前的文章中其实有介绍到这个容器,如何用 Decorator 装饰你的 Typescript,所以这里就不再赘述了,其实就是一些基础功能的封装。所以也就是解释了event的目录存在。

而这里modulesconponents最大的区别就是,复杂度和内部状态管理。如果内部状态较为复杂,且有很多的交互,那么我们就称之为 module.是的,这里的界限,我们划分较为模糊。

但是当你拿到一份设计稿的时候,估计就能明白我的良苦用心了~

红色框就可以理解为 module,绿色框可以理解为 components

page 的组织

针对单个页面里面的组织,其实都大同小异。(突然发现前端架构没有太多可言)

目录区分的并不是很多,但是也都较为清晰。简单介绍下每个区域的分工,需要展开的,我们在后续展开介绍

  • index.tsx 页面的入口文件,但是本身里面不会编写太多业务逻辑
  • utils 该页面的工具函数,包括接口的请求、数据的 format
  • customized-hooks 自定义hooks,这里有两个,初始化 UI 所需要的数据(边距等),业务请求的数据。
  • constants 页面的常量,包括请求的 apispm 埋点、固定的一些该页面业务数据等
  • components 该页面的组件(注意这里没有 module,因为太多了真的容易混乱),页面的 components,有简单的,也有复杂的。

以上就是一些目录结构和代码组织的交代。其实还是比较简单清晰的。下面介绍下

页面数据流向和管理规则

碎碎叨叨道不到个明明白白

因为是业务代码,所以这里就不会粘贴太多代码了

简单的解释下上面的流程

初始化 UI 的逻辑比较偏于业务,其实没有太多可借鉴的。这里我代码里面的工作也就是适配 iPhone X的一些UI。

重点说下初始化接口数据的过程吧。其实也就是各个页面中的 components 的状态初始化

interface

首先我们需要定义每一个模块的 props,毕竟是因为用的 ts注释即文档。所以我们将每一个 componentsprops 都定义到 type 目录中,毕竟很多时候接口返回的数据,需要我们做一次 format,而这个 format 的目的就是为了 components 更好的使用。换句话说,这些接口,可复用! 那必然定义到外面

注意接口上都要写注释啊!!!!理由如下:

将所有数据处理的方法,全部放到 utils 中(注意数据兜底的处理,这里我所有的数据处理都写好工具函数,并添加充分的单元测试)

真正的做到对 components 而言,开箱即用

因为有 type 的定义和 components 之间的约束,所以无论是componemts 内部的数据使用还是 index.tsx 里面的模块引入时 props 的注入,都有很好的约束

编写时候的提醒

漏写时候的报错

组件通信

由于我们使用了 hooks,且相对隔离的组件划分,原则上,组件通信其实并不是很多。当然,也必然是有的。

其实这方面的约束主要归结于业务的复杂度,如果数据逻辑比较复杂,且通信较多。那么可以考虑使用 useContextuseReducer

说下这次需求中涉及到的通信。

原则:组件尽可能值管理自己的状态。

遵循如上原则,最终的业务交互逻辑都是由组件内部管理,涉及到的同级通信则通过父组件操作。而父组件操作的原则就是只拿数据,不做任何业务处理。(尽可能的撇清关系)

约束

  • index尽可能不写业务逻辑
  • UI 初始化和模块数据初始化需自定义 hooks
  • 状态尽可能抽离。component 过于复杂需额外抽离 componentutilscustomized-hooks 等。参照上文
  • componentprops 需抽离复用
  • 公共 utils 方法编写充分的单元测试
  • 公共 utils 的方法导出需单独导出(bundle 大小),且编写注释(调用时候的提醒)
  • 尽可能定义 interface,并且编写注释.毕竟注释即文档

以上约束后期应该都会编写相应的 Eslint 来进行强约束(咳咳,程序猿基本素养不可靠)

最后看下我正在补充的单元测试,编写单元测试过程中,的确发现了不少工具函数的边缘情况处理的有问题

结束语

按照如上的 page 代码组织后面又写了一个页面,感觉代码的组织和状态的管理还是较为清晰的。后续会编写相应的 cli 来自动生成页面基础架构,比如 pmCli add page or pmCli add com

因为本文不方便粘贴太多代码,所以可能说的有些云里雾里,有任何疑问,欢迎公众号内回复【1】,加入全栈技术交流③群,一起交流

最后,本文只做一个抛转,并非定义一种规范。更多的约束和组织,希望大家多多交流,互相学习。

学习交流

  • 关注公众号【全栈前端精选】,每日获取好文推荐
  • 添加微信号:is_Nealyang(备注来源) ,入群交流
公众号【全栈前端精选】个人微信【is_Nealyang】
查看原文

赞 0 收藏 0 评论 0

isNealyang 发布了文章 · 1月7日

【THE LAST TIME】深入浅出 JavaScript 模块化

前言

The last time, I have learned

【THE LAST TIME】一直是我想写的一个系列,旨在厚积薄发,重温前端。

也是对自己的查缺补漏和技术分享。

欢迎大家多多评论指点吐槽。

系列文章均首发于公众号【全栈前端精选】,笔者文章集合详见GitHub 地址:Nealyang/personalBlog。目录和发文顺序皆为暂定

随着互联网的发展,前端开发也变的越来越复杂,从一开始的表单验证到现在动不动上千上万行代码的项目开发,团队协作就是我们不可避免的工作方式,为了更好地管理功能逻辑,模块化的概念也就渐渐产生了。

好的书籍📚会分章节,好的代码得分模块。

JavaScript 在早期的设计中就没有模块、包甚至类的概念,虽然 ES6 中有了 class 关键字,那也只是个语法糖。随意随着项目复杂度的增加,开发者必然需要模拟类的功能,来隔离、封装、组织复杂的 JavaScript 代码,而这种封装和隔离,也被被我们称之为模块化。

模块就是一个实现特定功能的文件 or 代码块。随着前端工程体系建设的愈发成熟,或许模块化的概念已经在前端圈子里已经耳熟能详了。

但是对于很多开发者而言,ES6 中的 exportimportnodejs 中的 requireexports.xxmodule.exports到底有什么区别?为什么又有 CommonJS,又有 AMDCMDUMD?区别是什么?甚至我们在编写 ts 文件的时候,还需要在配置文件里面说明什么模块方式,在项目中使用的时候,我们又是否真正知道,你用的到底是基于哪一种规范的模块化?

本文对你写代码没有一点帮助,但是如果你还对上述的问题存有疑惑或者想了解JavaScript 模块化的前世古今,那么我们开始吧~

公众号回复【xmind2】获取源文件

模块化的价值

所谓的模块化,粗俗的讲,就是把一大坨代码,一铲一铲分成一个个小小坨。当然,这种分割也必须是合理的,以便于你增减或者修改功能,并且不会影响整体系统的稳定性。

个人认为模块化具有以下几个好处:

  • 可维护性,每一个模块都是独立的。良好的设计能够极大的降低项目的耦合度。以便于其能独立于别的功能被整改。至少维护一个独立的功能模块,比维护一坨凌乱的代码要容易很多。
  • 减少全局变量污染,前端开发的初期,我们都在为全局变量而头疼,因为经常会触发一些难以排查且非技术性的 bug。当一些无关的代码一不小心重名了全局变量,我们就会遇到烦人的“命名空间污染”的问题。在模块化规范没有确定之前,其实我们都在极力的避免于此。(后文会介绍)
  • 可复用性,前端模块功能的封装,极大的提高了代码的可复用性。这点应该就不用详细说明了。想想从 npm 上找 package 的时候,是在干啥?
  • 方便管理依赖关系,在模块化规范没有完全确定的时候,模块之间相互依赖的关系非常的模糊,完全取决于 js 文件引入的顺序。粗俗!丝毫没有技术含量,不仅依赖模糊且难以维护。

原始模块化

对于某一工程作业或者行为进行定性的信息规定。主要是因为无法精准定量而形成的标准,所以,被称为规范。在模块化还没有规范确定的时候,我们都称之为原始模块化。

函数封装

回到我们刚刚说的模块的定义,模块就是一个实现特定功能的文件 or 代码块(这是我自己给定义的)。专业定义是,在程序设计中,为完成某一功能所需的一段程序或子程序;或指能由编译程序、装配程序等处理的独立程序单位;或指大型软件系统的一部分。而函数的一个功能就是实现特定逻辑的一组语句打包。并且 JavaScript 的作用域就是基于函数的。所以最原始之处,函数必然是作为模块化的第一步。

基本语法

//函数1
function fn1(){
  //statement
}
//函数2
function fn2(){
  //statement
}

优点

  • 有一定的功能隔离和封装...

缺点

  • 污染了全局变量
  • 模块之间的关系模糊

对象封装

其实就是把变量名塞的深一点。。。

基本语法

let module1 = {
  let tag : 1,
  let name:'module1',
  
  fun1(){
    console.log('this is fun1')
  },
  
  fun2(){
    console.log('this is fun2')
  }
}

我们在使用的时候呢,就直接

module1.fun2();

优点

  • 一定程度上优化了命名冲突,降低了全局变量污染的风险
  • 有一定的模块封装和隔离,并且还可以进一步语义化一些

缺点

  • 并没有实质上改变命名冲突的问题
  • 外部可以随意修改内部成员变量,还是容易产生意外风险

IIFE

IIFE 就是立即执行函数,我们可以通过匿名闭包的形式来实现模块化

基本语法

let global = 'Hello, I am a global variable :)';

(function () {
  // 在函数的作用域中下面的变量是私有的

  const myGrades = [93, 95, 88, 0, 55, 91];

  let average = function() {
    let total = myGrades.reduce(function(accumulator, item) {
      return accumulator + item}, 0);

    return 'Your average grade is ' + total / myGrades.length + '.';
  }

  let failing = function(){
    let failingGrades = myGrades.filter(function(item) {
      return item < 70;});

    return 'You failed ' + failingGrades.length + ' times.';
  }

  console.log(failing());
  console.log(global);
}());

// 控制台显示:'You failed 2 times.'
// 控制台显示:'Hello, I am a global variable :)'

这种方法的好处在于,你可以在函数内部使用局部变量,而不会意外覆盖同名全局变量,但仍然能够访问到全局变量

类似如上的 IIFE ,还有非常多的演进写法

比如引入依赖:

// module.js文件
(function(window, $) {
  let data = 'www.baidu.com'
  //操作数据的函数
  function foo() {
    //用于暴露有函数
    console.log(`foo() ${data}`)
    $('body').css('background', 'red')
  }
  function bar() {
    //用于暴露有函数
    console.log(`bar() ${data}`)
    otherFun() //内部调用
  }
  function otherFun() {
    //内部私有的函数
    console.log('otherFun()')
  }
  //暴露行为
  window.myModule = { foo, bar }
})(window, jQuery)
 // index.html文件
  <!-- 引入的js必须有一定顺序 -->
  <script type="text/javascript" data-original="jquery-1.10.1.js"></script>
  <script type="text/javascript" data-original="module.js"></script>
  <script type="text/javascript">
    myModule.foo()
  </script>

还有一种所谓的揭示模块模式 Revealing module pattern

var myGradesCalculate = (function () {

   // 在函数的作用域中下面的变量是私有的
  var myGrades = [93, 95, 88, 0, 55, 91];

  var average = function() {
    var total = myGrades.reduce(function(accumulator, item) {
      return accumulator + item;
      }, 0);

    return'Your average grade is ' + total / myGrades.length + '.';
  };

  var failing = function() {
    var failingGrades = myGrades.filter(function(item) {
        return item < 70;
      });

    return 'You failed ' + failingGrades.length + ' times.';
  };

  // 将公有指针指向私有方法

  return {
    average: average,
    failing: failing
  }
})();

myGradesCalculate.failing(); // 'You failed 2 times.' 
myGradesCalculate.average(); // 'Your average grade is 70.33333333333333.'

这和我们之前的实现方法非常相近,除了它会确保,在所有的变量和方法暴露之前都会保持私有.

优点

  • 实现了基本的封装
  • 只暴露对外的方法操作,有了 publicprivate 的概念

缺点

  • 模块依赖关系模糊

CommonJS

上述的所有解决方案都有一个共同点:使用单个全局变量来把所有的代码包含在一个函数内,由此来创建私有的命名空间和闭包作用域。

虽然每种方法都比较有效,但也都有各自的短板。

随着大前端时代的到来,常见的 JavaScript 模块规范也就有了:CommonJSAMDCMDUMDES6 原生。

基本介绍

CommonJS 是 JavaScript 的一个模块化规范,主要用于服务端Nodejs 中,当然,通过转换打包,也可以运行在浏览器端。毕竟服务端加载的模块都是存放于本地磁盘中,所以加载起来比较快,不需要考虑异步方式。

根据规范,每一个文件既是一个模块,其内部定义的变量是属于这个模块的,不会污染全局变量。

CommonJS 的核心思想是通过 require 方法来同步加载所依赖的模块,然后通过 exports 或者 module.exprots 来导出对外暴露的接口。

模块定义

CommonJS 的规范说明,一个单独的文件就是一个模块,也就是一个单独的作用域。并且模块只有一个出口,module.exports/exports.xxx

// lib/math.js
const NAME='Nealayng';
module.exports.author = NAME;
module.exports.add = (a,b)=> a+b;

加载模块

加载模块使用 require 方法,该方法读取文件并且执行,返回文件中 module.exports 对象

// main.js
const mathLib = require('./lib/math');

console.log(mathLib.author);//Nealyang
console.log(mathLib.add(1,2));// 3

在浏览器中使用 CommonJS

由于浏览器不支持 CommonJS 规范,因为其根本没有 moduleexportsrequire 等变量,如果要使用,则必须转换格式。Browserify是目前最常用的CommonJS格式转换的工具,我们可以通过安装browserify来对其进行转换.但是我们仍然需要注意,由于 CommonJS 的规范是阻塞式加载,并且模块文件存放在服务器端,可能会出现假死的等待状态。

npm i browserify -g

然后使用如下命令

browserify main.js -o js/bundle/main.js

然后在 HTML 中引入使用即可。

有一说一,在浏览器中使用 CommonJS 的规范去加载模块,真的不是很方便。如果一定要使用,我们可以使用browserify编译打包,也可以使用require1k,直接在浏览器上运行即可。

特点

  • 以文件为一个单元模块,代码运行在模块作用域内,不会污染全局变量
  • 同步加载模块,在服务端直接读取本地磁盘没问题,不太适用于浏览器
  • 模块可以加载多次,但是只会在第一次加载时运行,然后在加载,就是读取的缓存文件。需清理缓存后才可再次读取文件内容
  • 模块加载的顺序,按照其在代码中出现的顺序
  • 导出的是值的拷贝,这一点和 ES6 有着很大的不同(后面会介绍到)

补充知识点

其实在 nodejs 中模块的实现并非完全按照 CommonJS 的规范来的,而是进行了取舍。

Node 中,一个文件是一个模块->module

源码定义如下:

function Module(id = '', parent) {
  this.id = id;
  this.path = path.dirname(id);
  this.exports = {};
  this.parent = parent;
  updateChildren(parent, this, false);
  this.filename = null;
  this.loaded = false;
  this.children = [];
}
//实例化一个模块
var module = new Module(filename, parent);

CommonJS 的一个模块,就是一个脚本文件。require命令第一次加载该脚本,就会执行整个脚本,然后在内存生成一个对象。

{
  id: '...',
  exports: { ... },
  loaded: true,
  ...
}

上面代码就是 Node 内部加载模块后生成的一个对象。该对象的id属性是模块名,exports属性是模块输出的各个接口,loaded属性是一个布尔值,表示该模块的脚本是否执行完毕。其他还有很多属性,这里都省略不介绍了。

以后需要用到这个模块的时候,就会到exports属性上面取值。即使再次执行require命令,也不会再次执行该模块,而是到缓存之中取值。也就是说,CommonJS 模块无论加载多少次,都只会在第一次加载时运行一次,以后再加载,就返回第一次运行的结果,除非手动清除系统缓存。

再去深究具体的实现细节。。那就。。。下一篇分享吧~

AMD

Asynchronous Module Definition:异步模块定义。

也就是解决我们上面说的 CommonJS 在浏览器端致命的问题:假死。

介绍

CommonJS规范加载模块是同步的,也就是说,只有加载完成,才能执行后面的操作。AMD规范则是异步加载模块,允许指定回调函数。

由于其并非原生 js 所支持的那种写法。所以使用 AMD 规范开发的时候就需要大名鼎鼎的函数库 require.js 的支持了。

require.js

https://github.com/requirejs/...

关于 require.js 的更详细使用说明可以参考官网 api:https://requirejs.org/docs/ap...

require.js 主要解决两个问题:

  • 异步加载模块
  • 模块之间依赖模糊

定义模块

define(id,[dependence],callback)
  • id,一个可选参数,说白了就是给模块取个名字,但是却是模块的唯一标识。如果没有提供则取脚本的文件名
  • dependence,以来的模块数组
  • callback,工厂方法,模块初始化的一些操作。如果是函数,应该只被执行一次。如果是对象,则为模块的输出值

使用模块

require([moduleName],callback);
  • moduleName,以来的模块数组
  • callback,即为依赖模块加载成功之后执行的回调函数(前端异步的通用解决方案),

data-main

<script data-original="scripts/require.js" data-main="scripts/app.js"></script>

data-main 指定入口文件,比如这里指定 scripts 下的 app.js 文件,那么只有直接或者间接与app.js有依赖关系的模块才会被插入到html中。

require.config

通过这个函数可以对requirejs进行灵活的配置,其参数为一个配置对象,配置项及含义如下:

  • baseUrl——用于加载模块的根路径。
  • paths——用于映射不存在根路径下面的模块路径。
  • shims——配置在脚本/模块外面并没有使用RequireJS的函数依赖并且初始化函数。假设underscore并没有使用 RequireJS定义,但是你还是想通过RequireJS来使用它,那么你就需要在配置中把它定义为一个shim
  • deps——加载依赖关系数组
require.config({
//默认情况下从这个文件开始拉去取资源
    baseUrl:'scripts/app',
//如果你的依赖模块以pb头,会从scripts/pb加载模块。
    paths:{
        pb:'../pb'
    },
// load backbone as a shim,所谓就是将没有采用requirejs方式定义
//模块的东西转变为requirejs模块
    shim:{
        'backbone':{
            deps:['underscore'],
            exports:'Backbone'
        }
    }
});

Demo 演示

  • 创建项目
|-js
  |-libs
    |-require.js
  |-modules
    |-article.js
    |-user.js
  |-main.js
|-index.html
  • 定义模块
// user.js文件
// 定义没有依赖的模块
define(function() {
  let author = 'Nealyang'
  function getAuthor() {
    return author.toUpperCase()
  }
  return { getAuthor } // 暴露模块
})
//article.js文件
// 定义有依赖的模块
define(['user'], function(user) {
  let name = 'THE LAST TIME'
  function consoleMsg() {
    console.log(`${name} by ${user.getAuthor()}`);
  }
  // 暴露模块
  return { consoleMsg }
})
// main.js
(function() {
  require.config({
    baseUrl: 'js/', //基本路径 出发点在根目录下
    paths: {
      //映射: 模块标识名: 路径
      article: './modules/article', //此处不能写成article.js,会报错
      user: './modules/user'
    }
  })
  require(['article'], function(alerter) {
    article.consoleMsg()
  })
})()
// index.html文件
<!DOCTYPE html>
<html>
  <head>
    <title>Modular Demo</title>
  </head>
  <body>
    <!-- 引入require.js并指定js主文件的入口 -->
    <script data-main="js/main" data-original="js/libs/require.js"></script>
  </body>
</html>

如果我们需要引入第三方库,则需要在 main.js 文件中引入

(function() {
  require.config({
    baseUrl: 'js/',
    paths: {
      article: './modules/article',
      user: './modules/user',
      // 第三方库模块
      jquery: './libs/jquery-1.10.1' //注意:写成jQuery会报错
    }
  })
  require(['article'], function(alerter) {
    article.consoleMsg()
  })
})()

特点

  • 异步加载模块,不会造成因网络问题而出现的假死装填
  • 显式地列出其依赖关系,并以函数(定义此模块的那个函数)参数的形式将这些依赖进行注入
  • 在模块开始时,加载所有所需依赖
关于 require.js 的使用,仔细看文档,其实还是有很多知识点的。但是鉴于我们着实现在使用不多(我也不熟),所以这里也就参考网上优秀文章和自己实践,抛砖引玉。

CMD

基本介绍

CMD是阿里的玉伯提出来的(大神的成长故事可在公众号回复【大佬】),js 的函数为 sea.js,它和 AMD 其实非常的相似,文件即为模块,但是其最主要的区别是实现了按需加载。推崇依赖就近的原则,模块延迟执行,而 AMD 所依赖模块式提前执行(requireJS 2.0 后也改为了延迟执行)

//AMD
define(['./a','./b'], function (a, b) {

  //依赖一开始就写好
  a.test();
  b.test();
});
  
//CMD
define(function (requie, exports, module) {
  
  //依赖可以就近书写
  var a = require('./a');
  a.test();
  
  ...
  //按需加载
  if (status) {
    var b = requie('./b');
    b.test();
  }
});

SeaJs

https://github.com/seajs/seajs

https://seajs.github.io/seajs...

准确的说 CMDSeaJS 在推广过程中对模块定义的规范化产物。

也可以说SeaJS 是一个遵循 CMD 规范的 JavaScript 模块加载框架,可以实现 JavaScript 的 CMD 模块化开发方式。

SeaJS 只是实现 JavaScript的模块化和按需加载,并未扩展 JavaScript 语言本身。SeaJS 的主要目的是让开发人员更加专注于代码本身,从繁重的 JavaScript 文件以及对象依赖处理中解放出来。

毫不夸张的说,我们现在详情页就是 SeaJS+Kissy。。。(即将升级)

Seajs 追求简单、自然的代码书写和组织方式,具有如下核心特性:

  • 简单友好的模块定义规范Sea.js 遵循 CMD 规范,可以像 Node.js 一般书写模块代码。
  • 自然直观的代码组织方式:依赖的自动加载、配置的简洁清晰,可以让我们更多地享受编码的乐趣。

Sea.js 还提供常用插件,非常有助于开发调试和性能优化,并具有丰富的可扩展接口。

Demo 演示

examples/
  |-- sea-modules      存放 seajs、jquery 等文件,这也是模块的部署目录
  |-- static           存放各个项目的 js、css 文件
  |     |-- hello
  |     |-- lucky
  |     `-- todo
  `-- app              存放 html 等文件
        |-- hello.html
        |-- lucky.html
        `-- todo.html

我们从 hello.html 入手,来瞧瞧使用 Sea.js 如何组织代码。

在 hello.html 页尾,通过 script 引入 sea.js 后,有一段配置代码


// seajs 的简单配置
seajs.config({
  base: "../sea-modules/",
  alias: {
    "jquery": "jquery/jquery/1.10.1/jquery.js"
  }
})

// 加载入口模块
seajs.use("../static/hello/src/main")

sea.js 在下载完成后,会自动加载入口模块。页面中的代码就这么简单。

这个小游戏有两个模块 spinning.js 和 main.js,遵循统一的写法:

// 所有模块都通过 define 来定义
define(function(require, exports, module) {

  // 通过 require 引入依赖
  var $ = require('jquery');
  var Spinning = require('./spinning');

  // 通过 exports 对外提供接口
  exports.doSomething = ...

  // 或者通过 module.exports 提供整个接口
  module.exports = ...

});

上面就是 Sea.js 推荐的 CMD 模块书写格式。如果你有使用过 Node.js,一切都很自然。

以上实例,来源于官网 Example。更多 Demo 查看:https://github.com/seajs/examples

特点

  • 相对自然的依赖声明风格,且社区不错
  • 文件即模块
  • 模块按需加载。
  • 推崇依赖就近的原则,模块延迟执行

UMD

UMD 其实我个人还是觉得非常。。。。不喜欢的。ifElseuniversal 了。。。。

基本介绍

UMDAMDCommonJS 的综合产物。如上所说,AMD 的用武之地是浏览器,非阻塞式加载。CommonJS 主要用于服务端 Nodejs 中使用。所以人们就想到了一个通用的模式 UMD(universal module definition)。来解决跨平台的问题。

没错!就是 ifElse 的写法。

核心思想就是:先判断是否支持Node.js的模块(exports)是否存在,存在则使用Node.js模块模式。

在判断是否支持AMD(define是否存在),存在则使用AMD方式加载模块。

常规用法

(function (window, factory) {
    if (typeof exports === 'object') {
     
        module.exports = factory();
    } else if (typeof define === 'function' && define.amd) {
     
        define(factory);
    } else {
     
        window.eventUtil = factory();
    }
})(this, function () {
    //module ...
});
关于 UMD 更多的example 可移步github:https://github.com/umdjs/umd

ES6

如果你一直读到现在,那么恭喜你,我们开始介绍我们最新的模块化了!

通过上面的介绍我们知道,要么模块化依赖环境,要么需要引入额外的类库。说到底就是社区找到的一种妥协方案然后得到了大家的认可。但是归根结底不是官方呀。终于,ECMAScript 官宣了模块化的支持,真正的规范

基本介绍

在ES6中,我们可以使用 import 关键字引入模块,通过 export 关键字导出模块,功能较之于前几个方案更为强大,也是我们所推崇的,但是由于ES6目前无法在所有浏览器中执行,所以,我们还需通过babel将不被支持的import编译为当前受到广泛支持的 require

ES6 的模块化汲取了 CommonJSAMD 的优点,拥有简洁的语法和异步的支持。并且写法也和 CommonJS 非常的相似。

关于 ES6 模块的基本用法相比大家都比较熟悉了。这里我们主要和 CommonJS 对比学习。

与 CommonJS 的差异

两大差异:

  • CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用
  • CommonJS 模块是运行时加载,ES6 模块是编译时输出接口

值拷贝&值引用

// lib/counter.js

var counter = 1;

function increment() {
  counter++;
}

function decrement() {
  counter--;
}

module.exports = {
  counter: counter,
  increment: increment,
  decrement: decrement
};


// src/main.js

var counter = require('../../lib/counter');

counter.increment();
console.log(counter.counter); // 1

在 main.js 当中的实例是和原本模块完全不相干的。这也就解释了为什么调用了 counter.increment() 之后仍然返回1。因为我们引入的 counter 变量和模块里的是两个不同的实例。

所以调用 counter.increment() 方法只会改变模块中的 counter .想要修改引入的 counter 只有手动一下啦:

counter.counter++;
console.log(counter.counter); // 2

而通过 import 语句,可以引入实时只读的模块:

// lib/counter.js
export let counter = 1;

export function increment() {
  counter++;
}

export function decrement() {
  counter--;
}


// src/main.js
import * as counter from '../../counter';

console.log(counter.counter); // 1
counter.increment();
console.log(counter.counter); // 2

加载 & 编译

因为 CommonJS 加载的是一个对象(module.exports),对象只有在有脚本运行的时候才能生成。而 ES6 模块不是一个对象,只是一个静态的定义。在代码解析阶段就会生成。

ES6 模块是编译时输出接口,因此有如下2个特点:

  • import 命令会被 JS 引擎静态分析,优先于模块内的其他内容执行
  • export 命令会有变量声明提升的效果,所以import 和 export 命令在模块中的位置并不影响程序的输出。
// a.js
console.log('a.js')
import { foo } from './b';

// b.js
export let foo = 1;
console.log('b.js 先执行');

// 执行结果:
// b.js 先执行
// a.js
// a.js
import { foo } from './b';
console.log('a.js');
export const bar = 1;
export const bar2 = () => {
  console.log('bar2');
}
export function bar3() {
  console.log('bar3');
}

// b.js
export let foo = 1;
import * as a from './a';
console.log(a);

// 执行结果:
// { bar: undefined, bar2: undefined, bar3: [Function: bar3] }
// a.js

循环加载的差异

“循环加载”(circular dependency)指的是,a脚本的执行依赖b脚本,而b脚本的执行又依赖a脚本。

// a.js
var b = require('b');

// b.js
var a = require('a');

循环加载如果处理不好,还可能导致递归加载,使得程序无法执行,因此应该避免出现。

在 CommonJS 中,脚本代码在 require 的时候,就会全部执行。一旦出现某个模块被"循环加载",就只输出已经执行的部分,还未执行的部分不会输出

// a.js
exports.done = false;
var b = require('./b.js');
console.log('在 a.js 之中,b.done = %j', b.done);
exports.done = true;
console.log('a.js 执行完毕');
// b.js
exports.done = false;
var a = require('./a.js');
console.log('在 b.js 之中,a.done = %j', a.done);
exports.done = true;
console.log('b.js 执行完毕');
// main.js
var a = require('./a.js');
var b = require('./b.js');
console.log('在 main.js 之中, a.done=%j, b.done=%j', a.done, b.done);

输出结果为:

在 b.js 之中,a.done = false
b.js 执行完毕
在 a.js 之中,b.done = true
a.js 执行完毕
在 main.js 之中, a.done=true, b.done=true

从上面我们可以看出:

  • b.js之中,a.js没有执行完毕,只执行了第一行。
  • main.js执行到第二行时,不会再次执行b.js,而是输出缓存的b.js的执行结果,即它的第四行

ES6 处理“循环加载”与 CommonJS 有本质的不同。ES6 模块是动态引用,如果使用import从一个模块加载变量(即import foo from 'foo'),那些变量不会被缓存,而是成为一个指向被加载模块的引用,需要开发者自己保证,真正取值的时候能够取到值。

// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar);
export let foo = 'foo';

// b.mjs
import {foo} from './a';
console.log('b.mjs');
console.log(foo);
export let bar = 'bar';

运行结果如下:

b.mjs
ReferenceError: foo is not defined

上面代码中,执行a.mjs以后会报错,foo变量未定义.

具体的执行结果如下:

  • 执行a.mjs以后,引擎发现它加载了b.mjs,因此会优先执行b.mjs,然后再执行a.mjs
  • 执行b.mjs的时候,已知它从a.mjs输入了foo接口,这时不会去执行a.mjs,而是认为这个接口已经存在了,继续往下执行。
  • 执行到第三行console.log(foo)的时候,才发现这个接口根本没定义,因此报错。

解决这个问题的方法,就是让b.mjs运行的时候,foo已经有定义了。这可以通过将foo写成函数来解决。

// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar());
function foo() { return 'foo' }
export {foo};

// b.mjs
import {foo} from './a';
console.log('b.mjs');
console.log(foo());
function bar() { return 'bar' }
export {bar};

最后执行结果为:

b.mjs
foo
a.mjs
bar

特点

  • 每一个模块加载多次, JS只执行一次, 如果下次再去加载同目录下同文件,直接从内存中读取。 一个模块就是一个单例,或者说就是一个对象
  • 代码是在模块作用域之中运行,而不是在全局作用域运行。模块内部的顶层变量,外部不可见。不会污染全局作用域;
  • 模块脚本自动采用严格模式,不管有没有声明use strict
  • 模块之中,可以使用import命令加载其他模块(.js后缀不可省略,需要提供绝对 URL 或相对 URL),也可以使用export命令输出对外接口
  • 模块之中,顶层的this关键字返回undefined,而不是指向window。也就是说,在模块顶层使用this关键字,是无意义的
关于 ES6 详细的模块的介绍,强烈推荐阮一峰的 ES6 入门和深入理解 ES6 一书

参考文献

学习交流

  • 关注公众号【全栈前端精选】,每日获取好文推荐
  • 添加微信号:is_Nealyang(备注来源) ,入群交流
公众号【全栈前端精选】个人微信【is_Nealyang】
查看原文

赞 2 收藏 2 评论 0

认证与成就

  • 获得 516 次点赞
  • 获得 35 枚徽章 获得 2 枚金徽章, 获得 9 枚银徽章, 获得 24 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2016-09-05
个人主页被 2.5k 人浏览