162023.2

跨界面数据同步更新:基于抽象数据源的前端数据管理模式

在一个应用中,同一个数据源被用在不同场景下,会被反复请求,数据更新后不太容易同步更新关联数据源,我们通过抽象数据源,提出了一套可充分管理数据的系统化方案。

前端开发中,从后端提供的接口请求数据和提交数据是非常基础的工作,但在我们很多业务中,我们会发现跨界面的数据同步更新成为非常棘手的问题。我认为,由于很大一部分前端对数据管理的方式非常粗糙,给后续很多开发工作和产品体验都带来了问题,要解决这一问题,我们需要从更深的层面去思考“前端数据管理”的设计问题。本文将结合我们腾讯投资项目组的实战经验,聊一聊我对这个问题的思考和实现方式。

背景

前端开发逐渐成熟,但于此同时也存在诸多问题。具体到本文的场景下,成熟是指我们的开发模块化、逻辑分离化,有很好的分层趋势,而诸多问题是指由于前端编程本身的一些局限以及生态的不成熟,在分层趋势下无法优秀的处理跨层级的通信传递问题。

我们以tapd的一个例子为例来看看。当我们在任务列表中更新了某个单的状态时,可以通过一个快捷弹窗进行更新,更新后新状态会被同步到列表中,这个体验非常好:

 

但是,当我们打开单的详情,在详情界面更新状态时,在点击关闭之后,状态却没有同步到列表,这个体验就不那么如人意:

 

当然,对于tapd团队来讲,他们可以有办法解决这个问题,甚至这个情况可能是他们的一个bug,漏掉了一段代码。但是,我们不针对tapd的这个情况,我们要思考的是,当我们在列表和详情两个页面进行操作时,如何在这两个界面之间同步数据的更新呢?

我们经常在一个页面,甚至跨层级的组件之间有类似的操作。在一个页面的某个角落里面进行了某个编辑,提交之后,要同步更新页面大老远的另外一个角落的数据,因为它们是关联的,可能其中一个是依赖另外一个进行计算得到的数据,当被依赖的数据发送变化时,依赖者应该及时更新,否则界面上就会出现数据不一致的问题,是明显的业务错误。

但是我们在开发时,往往这两个地方是分两个组件开发的,而且很有可能这两个组件之间跨了十万八千里的层级,想要轻易通信没那么容易。面对这样的场景,我们应该如何去思考,如何去找到一种合理的方式呢?

备选方案

愚公移山法

例如上面的两个组件我们称为A和B,其中B组件内可能发生更新,更新后需要同步更新A组件。我们通过在A上暴露出一个方法,通过组件网络,层层传递,把这个方法传入到B中,当B完成更新后,调用这个方法,这个方法会帮助A重新请求数据,并完成界面更新。

全局状态法

我们建立了一个全局状态,该状态同时被A和B使用,当B完成更新后,我们重新请求数据,把新的数据写入到全局状态,引起两个组件的同时更新。但是有一个问题,我们是否需要基于不同的业务对象ID存储无数个类似的全局状态?

事件通知法

使用类似EventBus一类的事件管理器,在B完成更新后,广播一个事件,A监听该事件,在监听回调中重新请求数据并更新界面。问题在于,我们怎么确保庞大的系统中,事件名是唯一的?

问题的本质

我们经常讲,界面是状态反映。在这中间,我们没有提到数据是什么。我们从后端拉取的数据是什么呢?是状态?是其他?这是问题其一。问题之二是,我们现有的编程技巧中,把数据请求回来作为渲染界面的依据,这种处理方式直接把数据的来源与界面进行了绑定,以至于对数据的过度依赖致使UI编程无法以纯粹的方式表达界面本身的呈现。另一方面,数据源和界面的点对点关系,忽略了数据本身的意义,破坏了数据本身的联动性,使得这种联动必须依赖于UI编程中的某种触发,而非其自己实现闭合回路。数据本身的生命形态被打破,UI编程又被数据锁死,这是导致本文所指问题的关键根源。

一种数据源管理服务层设计

万事不决,分离一层。

我认为,在我们纯粹的UI编程和数据之间缺少一层隔离,所以界面和数据源之间是直接点对点绑定的,就像电话两头一样,只能两个人参与。而我们所面临的问题需要我们像水管一样,水源来了,你可以洗手盆出水,也可以花洒出水,都是用水,但用法不同,是一对多分发式参与。

在数据源和具体界面之间,并非一对一的关系,而且很多情况下,一个界面不一定只依赖一个数据源。因此,我们应该可以有这样一种形态:界面只管取数据来用,数据从哪里来我并不关心;于此同时,数据变了,我界面跟着变。通过这种隔离,让数据本身的生命由数据层自己掌控,让UI界面编程不在乎后端接口的绑架,从而做到两端的分离。

我们举个例子来感受一下:

我现在有了一个数据,它的格式为 { name: string, count: number },那么在任何界面中,我都可以将它取出来用,至于这个数据从哪里来,此刻我们并不关心。我们的组件需要用到这个数据,因此,我们把它读取出来。当这个数据发生变化(比如提交了更新,或者websocket推送了新数据)的时候,我们的界面随之变化。我们的组件只是像某个东西索取了这份数据,并且基于typescript,我们知道这份数据的格式和类型,至于这份数据是怎么来的,是从后端接口拉取的还是从缓存读取的,是由单一接口还是多个接口组合而来的,对于我组件而言,我不关心,我只关心我能拿到这份数据。

function SomeComponent() {
  const [data] = useDataSource(SomeDataSource)
  const { name, count } = data

  return (
    <div>{name}: {count}</div>
  )
}

那么这个数据是怎么来的呢?当然是从我们的后端接口来的了。

const SomeDataSource = source(async () => {
  const data = await fetch('...')
  return data
}, { name: '', count: 0 })

这个数据可能发生变化,什么时候发生变化呢?当然是某次更新操作之后。

await postData(data)
await renew(SomeDataSource)

当执行 `renew` 时,SomeDataSource 中的请求函数会被再次执行,新拿到的数据将作为 SomeDataSource 的新数据,由于这一变更会带来 useDataSource 内部实现的状态变更,从而引起 SomeComponent 的重新渲染。

假如,我们在另外一个组件中也使用了该数据源:

function Some2Component() {
  const [data] = useDataSource(SomeDataSource)
  ...
}

那么,当 SomeDataSource 中对应数据的变化,就回同时带来 Some2Component 的重新渲染。

你可能会说,这有什么,感觉完全没什么难度。但是一旦你开始按照这种思路去设计和架构你的代码,你就会发现,数据和组件开始分离,组件专注于使用数据,而数据有了自己的生命形态。

数据源管理服务层

在腾讯投资项目中,我们建立了一层服务层,专门用于管理对应的数据源,称之为数据源管理服务层。数据源管理和数据请求是两个层面的事物,数据请求负责从服务端拉取数据,二数据源管理负责管理这些数据。“数据源”是一个抽象,例如我们有一个叫做“项目详情”的数据源,但它并不代表一个具体的接口数据,而是代表项目这个概念的数据,抽象和具体的关系,类似interface和implements的关系。这个“项目详情”数据源,可能对应无数个具体项目的数据,但是它是一个源,它内部组织着不同项目数据的存储,所以它是一个管理工具。

数据源对象维持着数据,这些数据在组件中被读取。但是,在没有组件之前,我们就可以对数据源进行编程。我们以某些具体的需求为目标,建立数据源,数据源的目标是像下游提供数据,所以它只关心输出的数据是准确的,而不关心数据从哪里来。数据源定义拉取数据时,我们可以从两个接口挑选对应数据组合起来作为数据源对应的数据。例如一个具体的项目详情,它需要包含项目的基本信息,也需要包含这个项目内的交易信息,但是对于后端而言,这两个信息是分开的,由两个接口提供,因此,我们在建这个数据源时,我们从这两个接口拿到数据,并合并为一个数据,存储在数据源中等待被使用。

projectDetail = source(async (projectId: string) => {
  const [project, transactions] = await Promise.all([
    fetch('project info', projectId),
    fetch('transactions info', projectId),
  ])
  return { ...project, transactions }
}, )

上面这段演示代码演示了我们如何创建这个数据源。当然,这不是一个标准做法,因为我们的数据可能从各种渠道来,甚至是本地持久化存储。

接下来,我们要讨论的就是“数据自己的生命循环体”。

我们在项目中创建了一个 DataService 的基类,该类内部实现了上述数据源管理的能力。我们通过一个具体的 DataService 来管理某一组有关联的数据源,它们之间构成了数据自己的生命循环体。我们来看下例子:

class ProjectService extends DataService {
  // 项目详情
  projectDetail = this.source(async (projectId: string) => {
    const [project, transactions] = await Promise.all([
      fetch('project info', projectId),
      fetch('transactions info', projectId),
    ])
    return { ...project, transactions }
  }, {})

  // 项目列表
  projectList = this.source(async () => {
    const data = await fetchProjectList()
    return data
  }, [])

  // 更新项目基本信息
  updateProject = this.action(async (projectId: string, data: IProjectData) => {
    await httpPut(`.../${projectId}`, data)
    await Promise.all([
      this.renew(this.projectDetail, projectId),
      this.renew(this.projectList),
    ])
  })
}

我们用这个最简单的例子来说明。对于项目而言,我们往往需要读取项目列表,和某个具体的项目详情,同时有更新项目基本信息,实际上,我们还会有更多,例如获取项目的某些统计数据等等。

当我们更新项目基本信息的时候,我们需要 renew 项目详情和项目列表数据。

关键的来了。

“我们现在已经对项目相关的数据了如指掌,且它们已经自成体系了。”以往,我们很难表达这种感觉,就是我们还没有去考虑交互上的点击提交等操作之前,我们的数据自己已经动起来了,而且形成了完整的闭环。即使脱离了界面的环境,这部分代码都非常有价值。对于 ProjectService 而言,它可以被实例化,以维持数据,也可以被垃圾回收,释放内存。

我们弱化了“请求”这个动作,强调了“数据”这个存在。以往我们要为一个字段的选项列表提供数据,我们常常是在组件中创建一个状态,然后用一个请求把列表拉回来,再给这个状态,把这个状态作为选项列表。但是,在我们项目中,我们强调数据,一个数据源是一个对象,因此可以被作为一件物品进行传递,在组件中我们接收这个对象,从该源对象中读取数据,过程中,我们不关心请求这个动作。

这种有价值的数据管理,在我们项目中,被独立为数据源管理服务层,它向应用中的很多地方提供数据,不单单是组件里面,还有一些计算,一些判断查询,还有模型中提供某个字段的选项列表,它形成了一套独立的服务体系。

业务模块的组织

在我们的代码中,我们习惯于将相关的代码放在一起,用以表达某种高内聚的关联关系。在我们的项目中,我们有两种组织的单元,一种称为Subject,它和具体的界面无关,甚至和应用的呈现无关,它具有纯粹的业务描述,基于DDD的理念进行建模;另一种是Module,是基于某个具体业务而组织在一起的代码,狭义的理解,你可以把一个Module理解为应用中某个模块,例如项目模块、付款模块、签署模块等等。而“数据源管理服务层”虽然被称为一层,但并不代表数据源的定义代码被集中放在一起管理,相反,我们遵循上述组织单元的原则,Subject中有自己的数据源管理服务,Module中也有自己的数据源管理服务。

业务与UI分离

数据源管理服务层是通用的,且与业务逻辑一起,被放在公共实现部分,基于这一设计,不同端不需要重复实现相同的数据请求,当然,实际上在我们实现UI时,我们会忘记“请求”这个概念,我们不“请求”,而是“索要”,向数据源索要需要的数据。感官上,我们在写组件时,很难直接与数据请求打交道,甚至脑海中不需要有“请求”的概念,只有当我们再做骨架屏,需要一个加载状态时,需要数据源管理服务层暴露一个 loading 的状态给到组件使用。

当写组件更纯粹的专注于UI和交互本身,而不是处理一大堆和请求、数据、业务逻辑相关的东西时,我们的写作会被解放出来。随着数据管理的清晰化,前后端在某种层面上形成了一种规范,例如每一个实体需要提供对应的详情、列表、编辑、更新、选项等接口,基于这一规范,开发效率也会有所提升。

跨组件数据联动

数据的动作,和前端没有直接关系,甚至不一定是前端提交数据带来的,例如系统中有一个定时机制,在特定时间会更新数据。在上面的示例代码中,我们有 projectDetail 和 projectList 两个数据源。我们往往在两个不同的界面中使用这两个数据。但当某个项目发生变化时,这两个界面都应该被实时更新。

function ListPage() {
  const service = useService(ProjectService)
  const [listData] = useDataSource(service.projectList)
  // 使用 listData
}

在列表页使用列表数据。

function DetailPage() {
  const { projectId } = useRouteParams()
  const service = useService(ProjectService)
  const [detailData] = useDataSource(service.projectDetail, projectId)
  // 使用 detailData
}

在详情页使用详情数据。

我们把系统中的所有 Service 设计为可使用单例的对象,所以上面两个组件实际上使用了相同的 ProjectService 的单例。

上面这两个页面在数据使用上,看似没有任何联系,但在 DataService 层面,它们是有联系的,当项目数据发生变化时,详情和列表都会更新。但对于组件的开发者,并不需要去关心这一点,组件开发者只需要关心自己使用了自己需要的数据。这符合 Clear Architecture 的理念,我们在写内层的实现时,不应该考虑外层的应用。

function ProjectEditForm(props) {
  const { projectId } = props
  const service = useService(ProjectService)
  
  const handleSubmit = async () => {
    const data = generatePostData()
    await service.request(service.updateProject, projectId, data)
  }

  // ...
}

在表单中更新项目基本信息。

当更新完成之后,在 ProjectService 里面,它调用 renew 来更新当前项目和项目列表的数据,从而让 ListPage 和 DetailPage 作出对应的变更。上面三个组件中,我们不需要去处理它们相互之间的联动关系,因为这个动作是由数据层完成的,而非表现层。而且,实际上,除了表现层可能用到这些数据,逻辑层也可能用到,这意味着除了界面的变更,实际上我们在逻辑层的处理也可以被重新执行。

前端项目的分层设计让不同部分的职责更单一,前端框架则负责在各个层之间建立响应式关系。

效果预览

在投资信息中,投资方和被投方有约见记录,录入约见记录是一个非常简单的操作。我们来看下,在这个录入操作下,使用上述方案的实际效果。

 

约见记录列表陈列了该公司已有的约见,当点击添加约见时,会进入到一个新页面(新路由),完成添加之后,会回到这个页面,而回来时,我们新增的约见被读取了出来。在两个页面之间进行不同的数据存取操作,虽然动作本身是分开的,但是它们存在内在的联系。在 DataService 中,将这两种内在联系绑定在一起,因此,完成提交操作时,列表数据源需要随即进行更新。但在组件开发时,我们不需要去关心这种内在联系,因为对于列表组件而言,我们不需要也不应该想着数据的更新,同时,添加表单组件也不可能穷举出自己需要联动触发哪些组件更新。对于这两个组件而言,它们只专注做自己的事情,把数据的联动交给数据源管理服务层。

结语

在腾讯投资相关系统中,经常使用这种模式。无论PC端还是APP端,使用这种模式进行开发可以让我们比较轻松的完成跨界面的数据联动。这种模式,首先将数据管理从UI编程中独立出来,建立一层数据源管理服务层,并且在该层中构建了数据自身的生命循环体;接着,借助框架的能力,让数据管理工具具备响应式能力,并通过hooks封装,让组件可以在数据发生变化时进行更新;最后,在UI编程时,开发者不需要思考自己的动作会触发其他哪些组件的变更,也不需要记着自己需要依赖其他组件的动作来重新渲染,开发者专注完成自己的实现,而把这种跨组件的联动交给数据源管理服务层和封装的hooks函数来实现。前端数据层在以往我们很少去深入思考,无非就是请求数据,但是,在这种数据管理模式下,我们实现了架构上的分层,让数据自治,把数据的管理从组件体系中分离出来,这使得数据本身更封闭更聚合,同时当为其他部分(UI或某些逻辑判断)提供服务时,具有更开放更简洁的出口。当然,这一模式在某种程度上,依赖项目代码的整体架构设计,没有分层的架构设计理念,很难适应这种模式,同时,它也依赖于某些技术上的实现,不然没法做到那么高程度的封装。不过,相信这种设计在很多业务系统中,需要跨组件跨界面实现数据联动的场景下,具有不错的借鉴意义。

09:56:33 已有0条回复
022023.2

vscode里面命名下划线替换为驼峰

方法很简单,利用替换功能实现

基于正则,注意大小写

11:10:34 已有0条回复
302023.1

基于构建的无侵入类方法实现:用webpack loader来改写原始类

在项目中我们需要针对不同的平台(Web和Native)做不同实现,在抽象中实现大部分功能,把细节实现留到具体平台的代码中,但是一直没有找到好的方法,如果直接在入口文件中引入实现文件,就会导致原始代码被打包的启动文件中,文件体积变大,但如果使用import()又无法确保每次代码加载都是ok的,总不可能自己实现顶层的await import,另外这些实现常常是侵入式的,需要对原始类进行方法覆盖。今天想到一种基于构建工具的无侵入式实现,这里无侵入是指把实现作为旁路代码,而不是主体入口代码。这种旁路代码有点像依赖注入,但是是从构建工具的角度来做,实际上也很简单,通过webpack的loader,把原始代码进行改写,把实现代码合并到原始代码中去。

/* eslint-disable @typescript-eslint/no-require-imports */

const fs = require('fs');
const path = require('path');

module.exports = function (contents) {
  const { resourcePath } = this;
  const options = this.getOptions();
  const { abstractDir, implementDir } = options;
  if (resourcePath.indexOf(abstractDir) === 0) {
    const implementFilePath = path.resolve(implementDir, resourcePath.replace(abstractDir, '.'));
    if (fs.existsSync(implementFilePath)) {
      const implementContents = fs.readFileSync(implementFilePath).toString();
      const newContents = composeFileContents(contents, implementContents);
      return newContents;
    }
  }
  return contents;
};

function composeFileContents(sourceContents, implementContents) {
  const sourceLines = sourceContents.split('\n');
  const implementLines = implementContents.split('\n');
  implementLines.shift(); // 去掉第一行,第一行是对原始文件(要被实现的文件)的引入

  const { imports: sourceImports, codes: sourceCodes } = splitCodes(sourceLines);
  const { imports: implementImports, codes: implementCodes } = splitCodes(implementLines);

  const { contents: imports } = composeImports(sourceImports, implementImports);
  const { contents: codes } = composeCodes(sourceCodes, implementCodes);

  return `${imports}\n${codes}`;
}

function splitCodes(lines) {
  const imports = [];
  const codes = [];

  let reach = false;
  let incomment = false;

  lines.forEach((line) => {
    const text = line.trim();
    const push = () => {
      if (reach) {
        codes.push(line);
      } else {
        imports.push(line);
      }
    };

    // 忽略注释
    if (text.indexOf('/*') === 0) {
      incomment = true;
      push();
      return;
    }
    if (text.substring(text.length - 2) === '*/') {
      incomment = false;
      push();
      return;
    }
    if (incomment) {
      push();
      return;
    }
    if (text.indexOf('//') === 0) {
      push();
      return;
    }

    if (text.indexOf('import ') === 0) {
      imports.push(line);
    } else {
      codes.push(line);
      reach = true;
    }
  });

  return { imports, codes };
}

function composeImports(sourceImports, implementImports) {
  // TODO: 需要考虑如果import了相同的变量名的问题
  const importMapping = {};
  const importVars = {};

  sourceImports.forEach((line) => {
    if (line.indexOf('import ') !== 0) {
      return;
    }
    const { vars, src, def } = parseImport(line);
    importMapping[src] = { vars, src, def };
    if (vars) {
      vars.forEach((v) => {
        importVars[v] = true;
      });
    }
    if (def) {
      importVars[def] = true;
    }
  });

  // TODO: 暂时未考虑default的冲突问题
  implementImports.forEach((line) => {
    const { vars, src, def } = parseImport(line);
    if (importMapping[src]) {
      const importVars = importMapping[src].vars;
      // TODO: 暂时未考虑import as后的变量名冲突问题
      if (vars && importVars) {
        importVars.push(...vars);
      }
    } else {
      importMapping[src] = { vars, src, def };
    }
  });

  // 先处理原始的
  const results = [];
  sourceImports.forEach((line) => {
    if (line.indexOf('import ') !== 0) {
      results.push(line);
      return;
    }
    const { src } = parseImport(line);
    const importText = createImport(importMapping[src]);
    results.push(importText);
    delete importMapping[src];
  });

  // 再处理多出来的
  const srcs = Object.keys(importMapping);
  srcs.forEach((src) => {
    const importText = createImport(importMapping[src]);
    results.push(importText);
  });

  return { contents: results.join('\n') };
}

function composeCodes(sourceCodes, implementCodes) {
  const source = sourceCodes.join('\n');
  const imports = `(function() {
    ${implementCodes.join('\n')}
  } ())`;
  return { contents: `${source}\n${imports}` };
}

function parseImport(importLine) {
  const [, exp, src] = importLine.match(/import ([\w\W]+) from ['"](.*?)['"]/m);

  const parseVar = (txt) => {
    // if (txt.indexOf(' as ') > -1) {
    //   const [, v] = item.split(' as ');
    //   return v.trim();
    // }
    const t = txt.trim();
    return t;
  };
  const parseVars = (txt) => {
    const t = txt.substring(1, txt.length - 1);
    const items = t.split(',');
    const list = items.map(parseVar);
    return list;
  };

  const txt = exp.trim();
  if (/^\{.*?\}$/.test(txt)) {
    const vars = parseVars(txt);
    return { vars, src };
  }
  if (/^\w+,.*?\}$/.test(txt)) {
    const [d, i] = txt.split(',').map((item) => item.trim());
    const vars = parseVars(i);
    const def = parseVar(d);
    return { src, vars, def };
  }
  const def = parseVar(txt);
  return { src, def };
}

function createImport(mapItem) {
  const { vars, src, def } = mapItem;
  if (def && vars) {
    return `import ${def}, { ${vars.join(', ')} } from '${src}';`;
  }
  if (def) {
    return `import ${def} from '${src}';`;
  }
  if (vars) {
    return `import { ${vars.join(', ')} } from '${src}';`;
  }
  return '';
}

这里的实现方式比较暴力,就是通过替换的形式,把实现代码合并进原始代码。这些代码一般被放在一个叫 @implements 的目录中,没有任何其他文件引用它们,构建工具根据其给定的路径进行匹配,如果路径匹配上了,就执行合并逻辑,因此称它们为旁路代码。不过,如果原始文件中缺少这部分实现,则无法运行。

虽然这种方式割裂了代码本身的逻辑,无法通过编辑器的源文件链接找到,但是,这种方式借助构建工具,使得代码层面更加清晰,最终的产物更加合理(主体代码中不存在与之无关的代码)。

19:50:01 已有0条回复
222023.1

如何终止fetch发出的请求?

我们知道xhr可以调用abort来终止请求,但是fetch如何终止却鲜有人知,其实非常简单,只是我们不够了解,即使用AbortController,具体如下:

const controller = new AbortController();
const signal = controller.signal;

const url = "video.mp4";
const downloadBtn = document.querySelector(".download");
const abortBtn = document.querySelector(".abort");

downloadBtn.addEventListener("click", fetchVideo);

abortBtn.addEventListener("click", () => {
  controller.abort();
  console.log("Download aborted");
});

function fetchVideo() {
  fetch(url, { signal })
    .then((response) => {
      console.log("Download complete", response);
    })
    .catch((err) => {
      console.error(`Download error: ${err.message}`);
    });
}

即在fetch时传入signal作为信令,当调用controller.abort()时,该信令就会发出终止请求的信号,fetch发出的请求就会被终止,其Promise会抛出一个AbortError。

21:47:53 已有0条回复
032023.1

前端代码中,哪些可以抽象,哪些不可以?

抽象的目的绝对不是复用,而是为了清晰的设计。从某种意义上讲,抽象分为两种,一种是绝对抽象,只有神态,没有实体,另一种是轮廓抽象,或者半抽象,有了基本的架子,留下空间去具体化。前一种我们常称之为接口interface,后一种我们常称之为抽象类abstract class。对于前端代码而言,在传统前端开发模式中,不存在这两种中的任何一种,对于abstract class则可能存在一些雏形,例如:

class Some {
stay() {
throw new Error('stay should be overrided')
}
}

此类处理虽然可行,但是在最终代码中会多出来许多没有用的代码,增加代码量。只有在我们引入ts之后,才有了真正的抽象代码。例如:

abstract class Some {
abstract stay(): void
}

这段代码定义了一个抽象类,它不能被new实例化,只能被extends扩展。扩展时,带有abstract前缀的成员必须被实现,否则编译阶段会报错,而在最终生成的代码中,不存在这些abstract成员,这样就可以使代码量最小化。

在具体业务中,哪些内容可以抽象,哪些不可以呢?

其实对于前端而言,基于interface的抽象(业务层面)几乎没有,我们很少会去写一个用于描述业务的interface,而且这完全没有必要。我们大部分情况下使用abstract class进行抽象,甚至在大部分情况下,不需要abstract,直接使用class进行抽象。这里抽象脱离了技术层面的抽象,而是对业务进行抽象,同时由于大部分前端场景中,同一业务是固定不变的,因此,这类抽象可以被具体实现。例如对于同一对象实体,我们直接对其进行建模。再例如,我们直接对数据请求进行建模。对于数据的操作,我们进行建模。总之,你会发现,抛开界面和交互的一切,都是可以进行建模处理的,这个部分全部可以抽象出来,在代码中形成一块封闭的可扩展的代码块,需要时被取出来使用,不需要时不import,对当前毫无影响。

最麻烦的是界面和交互的抽象。

先看下交互的抽象,由于交互动作往往会引起界面的变化,所以我们在对交互进行建模时,就必须预留下可产生界面变化的abstract成员,从而可以使界面产生变化。例如:

abstract class SomeView extends View {
@inject(SomeController)
controller: SomeController

abstract confirm(message: string, onOk: Function): void

deleteItem(id) {
this.confirm('确定删除吗?', () => {
// 执行删除
})
}
}

上面代码中,我们预留了一个confirm方法,对于具体的某个view而言,必须扩展这个confirm方法来,为什么要留呢?因为confirm往往需要弹出一个对话框,同时,这个对话框一定是一个中间态,用户点击它的按钮之后,一定还会有后续界面变化。这种界面的流动过程,无法通过简单的处理来实现。当然,如果你想具体化,还可以借助react的state,例如:

class SomeView extends View {
state = {
showConfirm: false,
deleteItemId: null,
}

deleteItem(id) {
this.setState({ showConfirm: true, deleteItemId: id })
}

handleDeleteItem() {
// 执行删除动作
}
}

通过以上方式确实可以做到提供完整的动作,但是这就意味着必须按照react的状态管理模式进行编程,而且对于使用方来说,一个动作的方法太多了,在实现时,不仅要调用deleteItem还要再调用handleDeleteItem。不过,从另外一个角度看,这似乎又是正确的一种做法。

最后看下界面的抽象。这个时最难的,因为不同端端界面呈现是不一样的。比如PC和APP上。但是我们也不是不能做,其前提是开发者,或者项目的架构师,在前期规划了非常细腻的业务组件,我们所有的业务开发,基于已有的业务组件进行,我们写代码,更想写配置或DSL,比如下面:

<Page>
<ProjectBasicInfo />
<ProjectMembers />
<ProjectDeals />
<Tabs>
<CompanyInfo />
<FinancingInfo />
</Tabs>
</Page>

这样一段代码,更像是一个页面的结构描述,至于每一个部分都具体展示什么内容,怎么展示,界面交互怎样,PC和APP上的差异,全靠业务组件内自己去实现,通过这种方式来进行界面的抽象,可以最大程度的抹平不同端的差异,但是对团队和架构师的要求会比较高,当然收益也是显而易见的,就是效率很高。

11:42:09 已有0条回复
262022.12

随着年龄的增长,学习成为一件越来越奢侈的事情

“活到老,学到老”听上去是一件非常容易的是,它告诉你,只要想学习,什么时候都不晚,但是在现实生活中,这种自以为是的哲学,本质上是钳住人思维的一种毒药。人的主要学习时间是在我们普通人常见的读书阶段,大学毕业之后,很难再持续学习。但当我们意识到这一点的时候,已经回不去了。“人最重要的投资,就是学习。”我觉得这句话及其重要,而且不仅适用于“投资自己学习”,也适用于“投资家人、下一代学习”。当然,投资是有风险的。我以前觉得,小孩子嘛,能否学的好不一定靠成绩体现,具有一种浪漫主义色彩的自由学习论。但随着年龄的增长,我们的很多思想都发生了变化。你可以说随波逐流,但我称之为逐渐意识到了前人的智慧。

学习本质上就是一种能力。在我们青年时期,我们的首要任务是学习“如何学习”这项能力。当我们习得这项能力之后,我们便可以用它“学习”其他的知识。因此,我们可以看到以前很多学渣,现在混的很好,因为我们在学校的时候,我们自以为是的认为考试成绩是以自己掌握的多少知识来进行打分,而实际上,我现在发现,考试成绩是以掌握知识与掌握知识的时间比来进行打分,所以哪些拥有100分的同学,应该思考一个问题,如果自己的100分是建立在100天的基础上,那么那些只用了3天临时抱佛脚得到70分的学渣,真的是学渣吗?本质上,我们的考试结果,是“学习效率比”,而学习效率比的本质,就是“学习的能力”。掌握了“如何学习”的能力与掌握了“通过学习获得的知识”相比,前者不仅简单的多,而且有利的多。任何的知识都会忘记,但是一旦掌握了“如何学习”,那么任何忘记的知识都可以快速重新获得,而如果我们把大量的时间用来掌握“通过学习获得的知识”,那么最后我们只能获得一堆很快就会忘记的知识,而不是获得一项终身受用的能力。

如果我们的工作不是做文学研究,就没有必要研读小说的每一个字,而是知道故事的梗概和作者的核心思想即可;如果我们的工作不是建筑设计师,那么就没有必要挖掘每一页照片和梁柱结构的细节,而是只要像浏览杂志一样从各种精美的构图中找到灵感即可;如果我们的工作不是系统工程师,那么就没有必要对每个自然段的来龙去脉理解的那么透彻,而是能够对章节大标题的主要内容和涉及问题有轮廓的理解即可。书尽读不如不读。我们只需要对自己所在专业,或者自己正在从事的工作所需要的知识,进行深读深专,而不应该纠缠于那些用于扩展思路的书籍中的细节。同样,面对开源项目也是这样。

不过,现在最关键的是,我们没有时间来进行如上的操作。除了平时工作时间占据了过长,生活琐事无休无止外,还有一个不得不面对的问题,就是社交媒体和短视频让我不“吸上两口”没法过好这一天。以上这些问题阻碍了我们在随着年龄增长过程中的持续学习过程。“持续学习”和“学习”本质是不同的,“学习”的本质是“学习如何学习”,而“持续学习”的本质是“通过发挥掌握的如何学习的能力,快速掌握目前自己需要的某些既能或想法,以开阔自己在工作或某项事务中的思路,帮助我解决问题”。如果人生没有“学习”,那么就只会停留在离开学校的那一刻,那一刻你什么样,就什么样;但如果人生没有“持续学习”,那么就没法解决问题,生活问题积累越多,人就会崩溃。我小时候经常听到“学习型社会”,意思是人们通过持续学习解决自己的生活问题,从而让社会和谐。但随着经济发展,这种提法被抹掉了,似乎对于某些人而言,矛盾越多,利益越大。

我前不久买了几本书,觉得应该很快可以看完,可今天发现,半本都没读完。人啊,有的时候对自己太自信,是对自己的侮辱。说来说去,还是因为懒,社交媒体短视频“吸”的不亦乐乎,哪还有心思读书。等到疫情来了,阳了,嗯,没时间刷短视频了,时间都用来照顾家人了,至于学习,早丢到九霄云外。人呐,有的时候有时间,觉得自己很富有,直到事情一来,才发现,自己不是富有,自己是蜉蝣。

23:21:13 已有0条回复
222022.11

有什么是vue能做到而react做不到的?

我在知乎讲react是全量更新脏检查,很多人听不懂,说我在乱说,我也懒得理会。我有一个例子,这个例子里面,我们可以看到react是全量更新,而vue可以做到定点更新(当然,vue是不是实现了另说)。我们来看下结构:

<a>
<b>
<c>
<d>
<e>

在这个结构里,我希望以直观的形式,去监督某个值变化,来实现<d>的更新,注意,这里<d>是一个node。在react里面,我们的写法可能是这样:

<SomeComponent attr={this.props.tableTips} />

如果想要更新SomeComponent的渲染结果,要么SomeComponent内部做了什么处理,让其自身可以根据某些条件重新渲染,要么必需是当前这个组件重新渲染,让SomeComponent被动重新渲染。但是为了重新渲染<d>而把a, b, c, e都重新渲染一遍,代价太大。有没有什么办法,在不重新渲染当前组件的情况下,重新渲染<d>这个片段?

一种方法是把d再封装一层,作为一个高阶组件,把需要监听的内容传入该封装好的组件,由组件来监听和重新渲染,这样就可以做到局部重新渲染,而不需要整个重新渲染。但是,这种方法也很笨,因为不可能存在一种万能的高阶组件来达到这个目的,所以到处去写这样的组件也不好。另外,高阶组件的使用,割裂了阅读组件结构的顺序,让我们在后期的维护中,不得不跳来跳去看代码。

而vue就可以做到基于模板来优化这一需求。例如:

<some-component attr="tableTips" />

就可以在模板编译时把tableTips作为可动态读取的片段,这样,每次在渲染some-component时,读取到的attr都可能是新的,从而带来重新渲染。

通过这个例子,我们就可以发现,虽然jsx具有非常强的表达力,但它始终基于原生语法,而作为模板语法,当然具有更强的能力。

11:41:01 已有3条回复
  1. 首先React肯定能局部渲染,不然官方文档就是笑话(https://reactjs.org/docs/rendering-elements.html#react-only-updates-whats-necessary)。

    Vue、React的渲染都是要通过vnode diff做更新,Vue也没细粒到标签级别的vnode,只能说Vue的tempalte编译期间做个太多的aot优化,Jsx因为要使用js/ts的全部能力,注定做不了很好的优化。
    #1253 断崖上的风 2022-11-22 13:33 回复
  2. 你说的那个局部渲染叫commit,不是一回事,麻烦看懂再来讨论
    #1256 回复给#1253 否子戈 2022-11-22 22:27 回复
  3. 如果你说的是前面的render过程,你说的对,默认情况下调用setState都会导致组件及其子孙的render方法的调用。

    这就是React跟Vue的不同之处。Vue基于订阅发布模型的数据响应可以在数据变化时准确知道应该通知谁要做更新;React推崇函数式,没有依赖收集过程,调用setState后无法知道依赖的数据是否更新,默认只能进行render,也因为粗暴的渲染机制,所以也才有了shouldComponentUpdate这样的api。

    以上是否就是你想说的?

    至于你说的脏检查,应该说的是渲染组件及其子孙吧,毕竟对应state,React没做任何检查。
    #1259 回复给#1256 断崖上的风 2022-11-26 10:32 回复
262022.10

物联网开发指南

072022.9

在前端,你聊建模和分层,总会被人烦

过去两年,我遇到比较尴尬的情况,就是无法很好的解释建模和分层的总要性和便利性,因为前端领域关于这块的东西共同语言比较少,大家都是在专注于视图层的新技术研究,而在更广泛的编程范式、原则上,比较少去探讨,因此,每当我聊到这个东西的时候,无法快速的聊核心思想,必须在外围给听者建立场景,举例子,但是这些例子在日常前端开发中,又不是非得按你这套才能实现,所以听者往往带着自己的想象和经验先对例子进行脑海复盘,然后来评价所谓建模、分层都太复杂,这就是非常尴尬的场面,你还没有开始聊建模和分层本身,就已经在前期预热阶段被听者否定,那么,后续所讲的任何话都没有任何意义。

前端建模和分层是非常重要的,它本身并不复杂,也不会增加工作量,问题在于它所要求开发者具备的思维方式、理念是比较庞大的,因此往往被抵触。如果你在完成一个需求时,提交代码进行review,直接被对方打回来,理由是不符合我们的开发理念,你一定也会很生气。但是这种东西往往需要有一个强有力的人去推动,他会去review每个人写的代码,是否按照分层理念去写,是否符合开放封闭原则,是否遵循整洁设计,是否考虑周期等等。而要确保这些,review人总需要花比较长的时间,除了那些对软件质量要求高的企业,很少有团队这样去做,包括现在国内的一线大厂,都不舍得这样去做,“我花钱来就是请你看别人代码的吗?”对他们来说,快速写出功能上线,才是最重要的,至于代码质量早被抛诸脑后,等到出了事,就“杀一个程序员祭天”。

在这样的环境下,把前端应用当作软件,追求其稳定性健壮性,就非常不合群,因为稳定健壮的软件,会淡化开发者本身的成就,只会把最终的成就摊平到日积月累的时间长河中去,除非是某个强有力的tech leader从古老的开始就把它当孩子般培养,否则没有人愿意做一些短期内看上去根本无法凸显自己价值的事,而且更可悲的是,软件健壮稳定了,用户本身根本没有任何感知,而没有感知就会直接忽略你的劳动成果,相反,那种到处漏洞,你天天救火的,在甲方看来,还觉得你态度好,能力也够。

所以,我已经在考虑避开聊建模分层这个话题了,我想接下来我会去研究一个新的领域,这个领域得有一定的需求,同时又有深度。

20:56:18 已有3条回复
  1. 整体上前端处于鄙视链底层也是有从业人员自身的原因。
    #1221 断崖上的风 2022-09-08 09:58 回复
  2. 太赞同你说的这些了,然后我又想起了这篇文章:http://www.lowcoder.cn/best-practice/detail?fid=BZxGdCrUX2。不知道你怎么看待目前的应用技术发展,比如近几年涌现的微前端,低码和跨端技术。
    #1222 戡玉 2022-09-08 11:09 回复
  3. 这块确实全在细节里,不容易被察觉。 但一个需要天天救火的项目和一个平稳运行的项目,甲方应该也是能看出差别的。
    #1225 1188 2022-09-16 18:29 回复
112022.8

typescript class static静态方法内this类型约束

typescript中只能对class成员进行约束,无法对static方法进行约束,我们通过一些特殊方法来实现。

/**
 * 用于得到某个class的构造函数,例如:
 * class Some {}
 * Constructor<Some> -> Some类型的构造函数,也就是class Some本身
 * 用处:
 * class Some {
 *  static fn<T>(this: Constructor<T>): void; // -> this: Constructor<T> 规定了该静态方法内的this类型,由于类型推导,此处的this被推导为Some本身
 * }
 */
export type Constructor<T> = new (...args: any[]) => T;

通过Constructor这个util,我们可以获得一个class的type,也就是class构造函数本身。所以,当我们在静态方法中进行this约束,或返回this时,可以这样:

class Some {
static create<T>(this: Constructor<T>): T & { a: number } {
// @ts-ignore extends 要求特定结构
return class extends this {
a: number = 1;
}
}
}

通过这样对static方法进行签名,就可以在使用时很好的约束类型。

10:30:33 已有0条回复