写点什么

技术复盘与总结

2020 年 3 月 22 日

技术复盘与总结

1 云鸽笔记的生态


一、生态

  • 云协作:多人员协作、项目资源共享、信息同步、共享编辑、周报管理等;

  • 云鸽笔记 PC 端:工作时间、快速操作、随时备忘;

  • 云鸽笔记 M 端:非工作时间、快速查阅、随时备忘、操作轻量。


二、生态图景

  • 云协作、云鸽笔记 PC 端



  • 云鸽笔记 M 端



三、生态**路引


2 云鸽笔记的项目管理实践

践行敏捷

在整个云鸽笔记的生态中,我们践行着敏捷开发的理念,实现了小版本的快速迭代和版本更新。从大的产品来看,逐步实现云鸽笔记的 PC 端、M 端,继而是云协作。


从小的产品功能来看,我们对产品功能进行了总体的梳理及评级,确定本期要做的功能列表。对本期功能列表进行工时与资源估算,并根据已有人员资源及时间安排,确定迭代版本的每一批功能列表。


在产品实现中,我们团队因没有测试资源及严格的项目管理资源,便就现有资源做了重新分配,并灵活的将测试放在每一个版本的迭代环节里,也充分利用了部门资源,在进行产品 MVP 验证的时候,同时做了必要的测试与优化提需。


研发人员在每天进行功能开发时,对紧急重要的 bugs 放在开发计划中,进行必要的代码重构与调整。


以下图示为我们实施中的基本方式。



3 云鸽笔记的设计理念

随着移动办公的普及,自带设备移动办公已成为现代办公的重要形式之一。我们本着解决私域用户工作及学习笔记的管理问题,分享和协作等场景进行设计。


从产品的架构来看,我们尽量用简洁的设计语言,功能更加纯粹,减少一些复杂的功能,极力降低用户的使用成本。用高效便捷的操作方式让信息层级更加清晰,增强用户体验。在视觉维度,通过字体模块的大小对比及其色调明度的变化来给用户提供更加舒适的阅读和记录的线上环境。然后通过合理的交互方式减少用户的操作成本和学习成本,缩短用户感知产品的时间,提升产品体验。


4 云鸽笔记的实现框架

一、功能模块

在这里,我们对现有功能模块进行了梳理和展示,主要从列表展示、内容展示、用户操作及账号同步、权限设置多方面展开。而这些功能模块的梳理也为我们编写框架提供了依据。




二、框架设计

在框架设计中,结合功能模块的梳理,我们遵循便捷、可复用的原则,采用以下框架完成多端设计与开发。



前端实施中,采用了 React+Mobx+Typescript 的技术框架,在每一个功能模块中创建其相应的 Service,以用来实现应用层 API 的封装;在 Store 中实现基础数据的获取或修改,可用于 PC 与 M 两个端,而在 PC 和 M 两端,分别实现对应其操作行为的数据 store。这样我们可以达到通用 store 在多端的复用,又不会给每个端带来没有负作用的多余代码。


在 UI 层面,使用 Yep-React 组件库,搭配构建工具 Rocketact/jdwtool,高效完成项目的构建、页面开发及项目的实时预览。


三、图片格式的选择

因为在项目中存在各式各样的图标,它们小而多,并且会随着操作行为发生色值的改变。综合考虑,我们选择使用 SVG 来控制图标的显示,更多原因如下:


  • 相比传统的图片,尺寸更小,可压缩性更强

  • 可伸缩,更清晰

  • 方便读取和修改

  • 设计软件直接导出


5 云鸽笔记的 PC 端的问题与优化

一、多个 svg 使用问题

svg 的使用方式多种多样,适合自己的才是最好的。下面简单介绍下我们的项目如何在 jdwtool 脚手架中使用了 svg。因为 jdwtool 基于 webpack 打包,所以 webpack 中必不可少需要增加针对 svg 的配置。代码如下:


// webpack.config.js{  test: /\.svg$/,  use: [  {    loader: path.resolve(__dirname, "./fdtsvgloader")  },    "svg-loader"  ],  include: [path.resolve(opts.baseDir, "src")],  exclude: [path.resolve(opts.baseDir, "src/svginline")]}
复制代码


我们依然使用 svg-loader 进行 svg 的处理,但 svg-loader 返回的是一个包含attributescontent的对象,无法直接使用。处理后的结果如下代码所示:


module.exports = { attributes: {  xmlns: 'http://www.w3.org/2000/svg',  viewBox: '0 0 1024 1024' }, content: '<path d="M441.9 167.3l-19.8-19.8c-4.7-4.7-12.3-4.7-17 0L224 328.2 42.9 147.5c-4.7-4.7-12.3-4.7-17 0L6.1 167.3c-4.7 4.7-4.7 12.3 0 17l209.4 209.4c4.7 4.7 12.3 4.7 17 0l209.4-209.4c4.7-4.7 4.7-12.3 0-17z"/>'}
复制代码


因此,在 fdt 中单独写了一个 loader 来得到想要的 svg 格式。代码如下:


// fdtsvgloader.jsmodule.exports = function(source) { return `  ${source}  var fdtsvg = require('fdt-svg-loader')  module.exports = fdtsvg(module.exports)  `;};
// fdt-svg-loadervar React = require("react");module.exports = function(svg) { const content = svg.content; return function(props) { const newprops = { viewBox: "0 0 1024 1024", height: "20px", fill: "#000" }; newprops.dangerouslySetInnerHTML = { __html: content }; newprops; return React.createElement("svg", { ...newprops, ...props }); };};
复制代码


其中,fdtsvgloader.js 中接受的参数即为 svg-loader 处理后的结果。最后,经过 fdt-svg-loader 处理后,得到了 React 创建的 svg 元素,并包含默认属性 viewBox,height 以及 fill 值。因此我们在组件中可以如下方式引用 svg:


// demo.tsximport PptIcon from "@/image/newppt.svg";export default class Demo extends Component {  render() {    return <PptIcon width="18" height="18" viewBox="0 0 27 34" />;  }}
复制代码


demo 中传递的属性便可覆盖默认属性,灵活控制 svg 的大小。至此,我们在项目中愉快的使用 svg 来控制各式各样图标的显示。



但是突然有一天,在一个慵懒的午后,测试同学突然告诉我,页面中的图标从左边变成了右边的样子。短暂的惊慌之后,我迅速抄起键盘寻找 bug 的所在之处。


实不相瞒,在做此项目之前,我较少涉猎 svg 的知识,对于 svg 并不是很熟悉。因此,寻找 bug 的过程中遇到了一些困难和挫折。在较短的时间内并没有迅速找到问题所在,首先想到的是重新更换一下图标(设计软件中导出的图标我们做过手动处理)。惊奇的发现,这个方法居然好用,果断完成上线。


事后慢慢琢磨这个事情,一个 svg 图片并没有受到外部 CSS 的影响,为什么会突然导致问题呢?为了更快的发现问题,我仔细研究了一下我们的 svg 图标的结构,学到了一些关于 svg 的内容:


  • <g>该标签代表组合

  • <defs>定义重用图形

  • <polygon>定义多边形

  • <mask>定义蒙层

  • <use>实现 SVG 现有图形的重用


既然无法直接找到答案,那只好上排除法来寻找问题所在了。最后发现,问题出现的原因是新引入的图标影响了原有图标。svg 互相影响也真的让我非常震惊。


那到底是怎么互相影响的呢?原因就是新的图标中定义了一个 mask 蒙层,属性 id 为mask-2。受影响的图标中,path 标签的 mask 属性引用了该mask-2的蒙层,导致新图标的出现影响了部分旧图标。


那么对于直接在 html 中引入 svg,浏览器对于重用图标的寻找机制是怎么样的呢?我们做了如下测试:


  <svg width="400" height="300">    <defs>      <linearGradient id='white2black'>        <stop offset="0" stop-color="white"></stop>        <stop offset="100%" stop-color="black"></stop>      </linearGradient>      <mask id="opacity">        <rect x="0" y="0" width="400" height="300" fill="url(#white2black)"></rect>      </mask>    </defs>    <rect id="back" x="0" y="0" width="400" height="300" fill="#d4fcff"></rect>    <rect id="front" x="0" y="0" width="400" height="300" fill="#fcd3db" mask="url(#opacity)"></rect>  </svg>
<svg width="600" height="300"> <defs> <linearGradient id='white2black'> <stop offset="0" stop-color="blue"></stop> <stop offset="50%" stop-color="black"></stop> <stop offset="100%" stop-color="green"></stop> </linearGradient> <mask id="opacity"> <rect x="50" y="0" width="600" height="400" fill="green"></rect> </mask> </defs> <rect id="back" x="0" y="0" width="400" height="300" fill="#d4fcff"></rect> <rect id="front" x="0" y="0" width="400" height="300" fill="#fcd3db" mask="url(#opacity)"></rect> </svg>
复制代码


该代码的展示效果为:



当把第一个 svg 的 mask 标签删除之后


  <svg width="400" height="300">    <defs>      <linearGradient id='white2black'>        <stop offset="0" stop-color="white"></stop>        <stop offset="100%" stop-color="black"></stop>      </linearGradient>    </defs>    <rect id="back" x="0" y="0" width="400" height="300" fill="#d4fcff"></rect>    <rect id="front" x="0" y="0" width="400" height="300" fill="#fcd3db" mask="url(#opacity)"></rect>  </svg>
<svg width="600" height="300"> <defs> <linearGradient id='white2black'> <stop offset="0" stop-color="blue"></stop> <stop offset="50%" stop-color="black"></stop> <stop offset="100%" stop-color="green"></stop> </linearGradient> <mask id="opacity"> <rect x="50" y="0" width="600" height="400" fill="green"></rect> </mask> </defs> <rect id="back" x="0" y="0" width="400" height="300" fill="#d4fcff"></rect> <rect id="front" x="0" y="0" width="400" height="300" fill="#fcd3db" mask="url(#opacity)"></rect> </svg>
复制代码


效果变为:



由此可以断定,svg 在寻找重用元素时的机制为:在当前 HTML 环境中寻找第一个匹配的元素。并不是想象中的在 svg 自己内部寻找或者逐层往外寻找。所以,在 html 中直接引入 svg 必然会存在互相影响的问题,也必然会带来一些未知的风险。那么到底有没有较为安全的方案呢?


方法总比困难多,解决方案肯定是存在的。为了防止互相影响,我们使用 css 中的 background-image 属性将 svg 引入,该方案中的 svg 在寻找重用元素时,仅仅会寻找自身标签内是否存在,而不会向外寻找,因此一定程度上保证了 svg 图标的安全性。


那以后要一直使用该种方案吗?我觉得还是分场景使用最为合适。例如:一成不变的 svg 图标可以采用 css 的方式引入,带有交互行为的图标可以采用 html 的方式引入,方便修改样式。当然,最重要的就是,对于直接使用的图标,svg 内最好干净的仅剩下 path 标签,这样不会带来任何问题。当然,svg sprites 使用 use 引用时也会存在问题。


二、第三方库引用新姿势

众所周知,React 本身推荐两种引用方法,一种是通过 HTML 的 script 标签引入 React,另一种是使用例如 Create-react-app 等脚手架启动大型 React 应用。前者是将 React 集成到现有项目最简单的方式,而大多数人在使用 React 框架的时候,往往选择后者,因为后者更方便扩展文件和组件的规模,使用 npm 的第三方库。


此时,我们需要将 React 安装在项目的 node_modules 中,并写入 package.json 文件的 dependence 字段,并使用 yarn.lock 锁住版本。这样,当多人协作完成项目或更换设备初始化代码时都会将所以依赖按照锁定的版本安装,保证项目的正常运行。这是一种很普遍、很常见的用法,具体内容无需赘述。在此,仅介绍一下我们的使用方法。


使用 script 的方式引入 React:



项目中依然使用:



同时并不将 React 安装到项目依赖中。看到这很多人会问,编辑器不会提示找不到 react 吗?我给出你的答案肯定是不会。


为什么呢?因为脚手架 jdwtool 做了一步处理。大家都知道,import 寻找依赖的机制是从当前项目中的 node_module 开始,依次往父级查找,最后查找到系统目录,如果在此过程中完成匹配,那查找到此为止。显然,引入的 react 并不会查找到,因为在系统目录也并未安装,那么到底怎么查找到的呢?这个就需要 tsconfig path 来为我们助力了。


使用 tsconfig 中的 path 字段进行路径映射,当项目中无法匹配到依赖时,会按照 path 中给出的字段进行匹配,直到匹配完成。动态生成的 path 如下:



其中,映射的路径为 xyz,先从 x 开始,直到 z 匹配完成。这里面包含了 jdwtool 的安装路径,将 React 安装到了 jdwtool 的 node_modules 下。因此,项目中 import react 时,可从 jdwtool 的 node_modules 下查找到 react,保证项目的正常运行。


下面,可以看一下项目的 dependence 字段:



从图中可以看到,项目中并不会安装一些通用的依赖,所有通用的依赖都安装到了 jdwtool 下,这就是我们整体的实现思路。


这么做到底有什么好处呢?


  • 一次加载,多处使用。加载一次 cdn 上的 react 文件,任何请求该文件的项目都可走缓存,而无需请求。

  • 方便升级 react 版本。大家都知道,react 总会给我们带来很多惊喜,尝试新版本总是变的迫不及待。使用这种方法,可以轻松升级 react 版本,而无需担心重新打包 vendor。

  • 脚手架安装依赖,项目初始化更快。很多的依赖都安装在了脚手架中,那么项目中安装的依赖变的非常少,这使得每个人 clone 下项目后,无需花费过多的时间等待依赖的安装。

  • 脚手架统一升级依赖版本。依赖都安装在脚手架的安装目录下,当需要升级依赖版本时,只需要更新脚手架即可。


看到这也许有人会问,假如某个项目不想使用脚手架的依赖怎么办?这个只需要在项目中安装指定版本的依赖即可。


那么为什么不将 antd 使用 script 的方式引入呢?因为 antd 我们使用了按需加载,而不像 react 这种全量加载。因此,对于需要全量加载的依赖,可以尝试使用这种方式引入。


三、antd icons 按需加载

前文我们提到过,对于 antd 这种可以按需加载组件的库,不使用 script 的方式全量引入。确实,antd 支持按需加载组件。实现按需加载组件的方式有两种,一种是单个组件分别引入对应的组件与样式,这种方式代码冗余,因此更多人喜欢第二种使用 babel-plugin-import 的方式实现按需加载。第二种具体的实现方式网上案例较多,脚手架也加入了对 antd 按需加载的处理,但这不是本段的主旨,本段的主旨是介绍 antd 中 Icon 组件的按需加载。


事情的起因是这样的,我们引用了一个 Icon 下的一个图标,然后看了下打包后的体积,发现增大了好多。于是查看了打包后的内容发现,antd 将所有的 Icon 的导入了。。。都导入了。。


看一下 antd 中 Icon 组件的源码,其中最引入注目的肯定是这段代码:



当调用 antd 的 Icon 组件时,会将所有的 Icon 导入。其实全部导入也没什么,关键是看一下 dist 的体积,500kb+。使用了一个 Icon 需要增大 500kb 的体积,这显然并不合适。因此,我们要寻找一种解决方案。


首先,在工具类目下创建一个文件,名为 icons.ts,内容如下:



icons.ts 中我们默认导出了两个需要使用的图标,而且项目中业仅仅使用了这两个图标。假如想使用更多的图标,那么也可以在该文件中写入。


其次,webpack 设置别名 alias。因为脚手架中的 webpack 配置均从 toml 文件中读取。所以,需要在 toml 中配置 webpack 的 alias,代码如下:



第三,在项目中正常使用 Icon。


这样一来,当引入 Icon 时,就把本来要引用的 dist 指向了自己写的 icons.ts。无需全部加载!避免使用一个 Icon 带来 500kb 网络开销的负担。


6 云鸽笔记的 M 端的技术难点解析

一、滚动列表的实现

在 M 端开始进入开发时,PC 端已基本完成。同步 PC 端的代码库,研究列表发现,使用了拥有虚拟滚动条的 InfiniteLoader 的代码库;在 M 端尝试使用该库,发现生成的页面层级过深,页面较复杂,虽虚拟滚动条看起来会顺畅,我们最终放弃了在 M 端的应用。


不过我们还是对 InfiniteLoader 做了进一步理解和尝试。比如如何动态设置行高,如何屏蔽在首屏数据未加载的情况下唤起下一次数据请求。


来一波伪代码:


<InfiniteLoader    isRowLoaded={isRowLoaded}    loadMoreRows={loadmore}    rowCount={count}    threshold={10}    minimumBatchSize={20}   >    {({ onRowsRendered, registerChild }) => (     <AutoSizer>      {({ height, width }) => (       <List        ref={registerChild}        onRowsRendered={onRowsRendered}        deferredMeasurementCache={cache}        rowHeight={cache.rowHeight}        rowRenderer={rowRenderer}       />      )}     </AutoSizer>    )}   </InfiniteLoader>
复制代码


const rowRenderer = ({ key, index, style, parent }) => {   return (    <CellMeasurer     cache={cache}     columnIndex={0}     key={key}     parent={parent}     rowIndex={index}    >     <div      className="infinitelist-item"     >     </div>    </CellMeasurer>   ) };
复制代码


其中 CellMeasurer 组件可以实现动态设置元素行高;而 threshold 、minimumBatchSize 和 defaultHeight 等混合应用才能解决不要在首屏首次列表数据未加载完毕时发起再次请求的问题。


二、列表页面切换保持切换前滚动高度

页面在切换时,能保持原页面的滚动高度,可以大大提升用户体验,不会让用户因在切换之间页面跳来跳去,有失控的感觉。还是回到刚才的 InfiniteLoader 组件,很强大,提供了上一个页面离开时的滚动高度。不过因为已废弃掉该组件在项目中的应用,需要自己来实现。不如把页面滚动和页面跳转间的关系画下来,按图索骥,便会一目了然。



上图已比较清晰的展示了页面滚动及页面跳转,只需找个地方把跳转前页面的 scrolltop 值存储下来,等再次回到该页面是,再取来去做跳转就好了。以下是伪代码展示:


// 写setScrollTopByPager(page: string, top: number) {  this.topHash[page] = top;};
// 读getScrollTopByPage(page: string) { return this.topHash[page];};
// 侦听滚动scrollNode.addEventListener('scroll', () => { this.setScrollTopForRoutor( this.pageId, scrollNode.scrollTop );})
// 再次进入页面时读取并设置let top = this.getScrollTopByPage(pageId);scrollNode.scrollTo(0, top);
复制代码


三、笔记的编辑和预览

作为云鸽笔记的核心功能,我们的定位是支持尽可能丰富的笔记格式和文档格式。对于笔记格式,支持常用的富文本及 markdown 格式,这两种格式提供编辑及预览;对于文档格式,关注下载及预览。


在编辑器方面,本期优先选择了第三方的编辑器类库,以快速实现 MVP 的验证。其中富文本编辑器选择了“braft-editor”;MarkDown 选择使用“tui-editor”。因为目前两者都比较成熟,使用较为简单,可更多关注内容的实现。


在文件预览方面,我们提供较为丰富的文件格式的在线预览,如大家常用的 Office 系列文档格式、PDF 格式,以及图片,还有作为程序员常用的代码类文件格式。


在设定目标及初步解决方案后,我们在实施过程中也遇到了一些问题,有些也是兼容性问题,以下一一展开。


四、MD 和富文本编辑

问题一:markdown 和富文本编辑器在编辑状态下,无法滚动到底部,有一部分被手机键盘遮挡。


原因在于 android 和 iOS 在键盘弹起和收起时页面的行为是完全不一样的——


  • iOS:iOS 的键盘在窗口的最上层,当键盘弹起时,webview 的高度并不会发生变化,但是 scrollTop 会发生变化,页面发生滚动,而页面可滚动的最大高度是弹出的键盘的高度;且只有当键盘弹起时页面滚动到底部时, scrollTop 的变化值才为键盘高度。

  • Android:webview 留出键盘空间,键盘弹起时页面高度会改变,内容区域会减少,页面不会发生滚动,flex 布局下会压缩页面。


针对 iOS 下这种情况,各种社区也提供了很多的解决方案,基本都是关于 input 标签 fixed 布局失效的案例,但是我们的场景比较特殊,笔记编辑的时候整个内容区域都是可编辑区,这就导致总有一部分内容会被键盘挡住无法被看见和选中编辑。


也尝试过使用以下方式


input.scrollIntoView(true);input.scrollIntoViewIfNeeded();
复制代码


另外我们希望笔记标题在顶部固定,内容区域和可编辑区域可滚动,因此采用了 flex 布局。但场景中可编辑区已经处于可视区域,所以并没能解决问题,还有重要的一点是键盘弹起之后编辑区域也是可滚动的,依然会有无法滚动到底部的问题,键盘还是会遮挡一部分内容。



这种场景并不一样,经过几番尝试均未达到想要的效果,于是尝试改变布局方案,不使用 flex 布局,使用最简单的流体布局,监听页面滚动,动态固定笔记标题。


 let noteHeader = document.querySelector('.note-header'); window.addEventListener('scroll', this.scrollFn, false); this.scrollFn = () => {  let positionTop = document.body.scrollTop;  noteHeader.style.top = positionTop + 'px'; }
复制代码


这样能让标题固定在顶部,由 js 动态控制,体验上差一些。键盘遮挡内容的问题还是没有解决,经过反复的实验,发现其实整个内容区域并没有滚动上去,键盘下面的位置还有一部分内容,因此需要在键盘弹起的时候将内容向上顶起,使这部分内容处于键盘上方,也就是在键盘弹起后给内容区域加 paddingBottom 属性,键盘下面区域用空白填充,内容即可滚动到键盘上方,先来实验一下。


let focusinFn = () => {  let content = document.querySelector('.content');  content.style.paddingBottom = '7.5rem';}
window.addEventListener('focusin', focusinFn, false);
复制代码


实验成功,可以按照这个思路继续优化了,这期是按照常规的最高键盘高度来设置的paddingBottom值来实现这个效果,键盘收起的时候重置成初始的状态,后续优化这快高度的设置。这块的实现比较粗糙,还有很大的优化空间,或许还有更好的解决方案。以此抛砖引玉,希望看到大家的想法。


五、PDF 和 Office 文件预览

问题二:office 文件预览,无法放大缩小,上层样式的影响无法滚动;


问题三:pdf 存在无法滚动和缩放的问题,体验很差。


原因主要是使用 html 标签在页面中渲染无法缩放和滚动。在预览 PDF 中,使用了embed标签加载;而 Office 文件使用了iframe加载。但无法滚动和缩放,使阅读体验大打折扣。后来我们改进了此方法,先下载文件,再使用手机的文件预览功能打开文档进行预览,实验成功。而收获的意外体验是,这时可以友好的提示用户是否要下载当前文档,给了用户更多的控制权。


axios({  method: "get",  url: `downloadurl`,  responseType: "blob",  onDownloadProgress: (progressEvent) => {   onDownloadProgress && onDownloadProgress(progressEvent);  } }).then(function(response) {  var a = document.createElement("a");  var url = window.URL.createObjectURL(response.data);  a.href = url;  a.download = name;  a.click();  window.URL.revokeObjectURL(url); });
复制代码


六、与京 ME 应用的打通

我们的产品定位是基于内部私域的实现,所以在账号体系的打通上我们使用了公司的 ERP 账号体系。而京 ME 是基于全员的必备 APP,也是部署产品的重要入口。


与此同时,京 ME 也为我们提供了一些必要的提高产品体验的 API,比如唤起手机拍照或相册、是否是 wifi 环境、分享功能;京 ME 还提供自定义导航栏;对于产品验证与发布,京 ME 还提供白名单测试及灰度发布。


不过在接入京 ME 中也遇到了一些问题。比如分享功能,可以分享给私域之外的地方,如微信微博朋友圈,而这些是我们不需要的,关于定制化的分享功能,则需要像京 ME 提需;同样还是分享功能,云鸽分享链接,因为咚咚在处理链接时出现了正则匹配问题,出现了跨平台间不同的展示结果,比如同一个链接在安卓下打开失败,iOS 下可用,而分享卡片的协议跳转,有时在 iOS 下不可用,而安卓 OK。


在开发过程中,由于京 Me 接入只支持 java 服务端,暂时不支持 nodejs 服务端,再了解京 Me 服务端认证算法后,我们同步在 nodejs 中做了实现,同时将相关功能封装为 koa 中间件 @jd/koa-jdme-passport ,具体使用可参考该中间件使用文档。


其间,京 ME 产品和研发小伙伴都鼎立协助我们解决问题,只是最终需要京 ME 发版,我们需要过一段时间再发布分享功能。


7 云鸽笔记的复盘总结

经过这一段时间的紧凑型项目开发及产品体验的提升,我们在项目中充分利用部门内部的工具资源,包括设计协作效率工具 REALY 平台(视觉稿一键标注),前端移动端 UI 组件库 Yep-react,内部构建工具等;对我们现有工具的实施与完善也做了进一步的验证和优化建议。


最重要的是,我们提出的产品方案,填补了内部笔记管理及团队间协作的空白,给笔记需求的伙伴一个很好的平台,也给需要文档协作的团队一个很好的选择。


最后,欢迎您随时使用云鸽笔记,有任何优化建议都可以提给我们,一起打磨更优质、有价值的笔记产品。


2020 年 3 月 22 日 21:05476

评论

发布
暂无评论
发现更多内容

华为发布“品质专线2.0&智能分布式接入”解决方案

Geek_459987

USDT支付系统开发解决方案服务商,承兑商支付

135深圳3055源中瑞8032

中年架构师,悲催的一天,全靠忍!

四猿外

Java 程序员 程序人生 架构师

MySQL-技术专题-MySQL中的锁机制

李浩宇/Alex

MySQL-技术专题-MySQL MVCC实现机制

李浩宇/Alex

使用Spring Boot创建docker image

程序那些事

Docker spring Spring Boot Spring Boot 2

算法图解:如何找出栈中的最小值?

王磊

Java 数据结构 算法

一个草根的日常杂碎(10月12日)

刘新吾

随笔杂谈 生活记录 社会百态

一个草根的日常杂碎(10月14日)

刘新吾

随笔杂谈 生活记录 社会百态

Java-技术专题-纤程库Quasar

李浩宇/Alex

华为:“智能分布式接入网”打造真千兆高品质生活体验

Geek_459987

为什么学习总是停在开头两页?

Nydia

区块链交易所开发搭建源码,数字货币交易系统

135深圳3055源中瑞8032

合约智能一键跟单系统开发,API合约跟单软件

135深圳3055源中瑞8032

一个你不得不重视的趋势,而且数据会让你很诧异

非著名程序员

个人成长 短视频 自媒体

区块链跨境支付系统开发源码,承兑系统搭建

WX13823153201

LeetCode题解:589. N叉树的前序遍历,栈,JavaScript,详细注释

Lee Chen

LeetCode 前端进阶训练营

如何优雅的搞垮服务器,再优雅的救活

MySQL从删库到跑路

Linux 升级glibc 启动异常 无法进入系统 抢救模式

架构师训练营第四周学习笔记

一马行千里

学习 极客大学架构师训练营

一个草根的日常杂碎(10月13日)

刘新吾

随笔杂谈 生活记录 社会百态

区块链钱包系统开发,多币钱包app源码搭建

135深圳3055源中瑞8032

系统架构--作业

Nick~毓

架构师训练营第 1 期第四周课后练习题

Leo乐

极客大学架构师训练营

架构师训练营第四周作业

脸不大

架构师训练营第四周命题作业

一马行千里

极客大学架构师训练营 命题作业

华为卢毅权:品质专线2.0 打造无处不在的品质联接

Geek_459987

LAXCUS大数据集群操作系统:一个分布式分时共享E级系统软件(二)

陈泽云

人工智能 大数据 基础设施 国产操作系统

代表Java未来的ZGC深度剖析,牛逼!

AI乔治

Java 架构 ZGC JVM GC调优

numexpr:你以为numpy已经够快了,其实它还可以更快

计算机与AI

Python 学习 数据分析 Numpy

甲方日常 31

句子

工作 随笔杂谈 日常

12张图带你彻底理解分布式事务产生的场景和解决方案!!

冰河

分布式事务 2PC 可靠消息最终一致 TCC 最大努力通知

技术为帆,纵横四海- Lazada技术东南亚探索和成长之旅

技术为帆,纵横四海- Lazada技术东南亚探索和成长之旅

技术复盘与总结-InfoQ