博客v0.8迭代记录

一拖再拖,终于将博客升级到了v0.8.0,主要涉及到底层框架react-vue、服务接口vite ssr、项目管理pnpm monorepo和部署方式docker compose等方面的改动,感觉可以记录一下。

<!--more-->

1. react-vue

1.1. 灵活的写法

在前面这篇博客:实现一个类React的Vue3框架中已经提到了相关的实现思路和方法。这里就大致回顾一下。

初始的想法是想用一种更灵活的方式写Vue3,没有什么比JSX更灵活的了,于是设计了唯一支持的组件结构

function Count({value = 0}: CountProps) {
  const data = reactive({
    count: value
  })
  const onClick = () => {
    data.count++
  }

  return  () => {
    return (<button onClick={onClick}>click {data.count} </button>)
  }
}

功能类似于Vue3的组件setup方法,区别在于返回的是一个render方法,当依赖变化触发视图更新时,会重新执行render方法参与diff算法,最后将变更同步到DOM中。

之前阅读了snabbdominfernoJS的源码

于是react-vue参考infernoJS的最长上升子序列算法,实现了一个简易的diff过程。

至于reactive等数据响应,就直接使用了@vue/reactivity这个库。

相较于之前写NeZha亲力亲为的做法,react-vue有很多地方都是参考vue3源码编写的,如

  • injectHook注册生命周期函数
  • watch监听指定getter的变化
  • queueJobflushJobseffect.scheduler实现的批量更新
  • provideinject跨父级组件传递数据

从这个库的名字就可以看出来,这次写的更随意了(不想太折腾了~)。

遇到不太容易实现的功能,就去翻一下Vue的源码,这个过程还是很有收获的,比如对于Vue3的props,为啥不能直接写成解构赋值的形式这个问题,在实现的时候就知道了答案

其本质是在mount时将props通过shallowReactive(props)放在了组件实例上,在updateComponent时,通过新的props更新旧的props,触发shallowReactive后旧props的setter,如果是解构赋值,render函数就不会触发props的getter,也就不会再触发render函数了

1.2. 需要支持SSR

由于博客的SEO非常重要,因此还是需要实现一下SSR,对于react-vue而言,主要需要实现renderHTMLhydrate这两个方法

renderHTML将给定的VNode渲染为HTML字符串,主要运行在server端,作为HTML相应返回

function renderHTML(root: VNode): string {
  startHydrate()
  // 首先将调用diff获取初始化组件节点,获取完整的节点树
  mount(root, {} as Element)
  stopHydrate()
  return VNode2HTML(root)
}

hydrate主要用于浏览器接收到server响应后的更新操作,复用已经被渲染的DOM节点

export function hydrate(root: VNode, dom: HTMLElement) {
  startHydrate()
  mount(root, {} as Element) // 获取完整的虚拟DOM树
  stopHydrate()
  return walk(root, dom.children[0])
}

两个方法的本质是相似的,都是通过深度优先遍历获得结果,具体的实现这里就不再赘述。

由于不太喜欢将isHydrate这个状态通过函数参数的方式在mount阶段一直透传下去,我定义了一个getHost方法,发布不同平台下对应节点操作的方法,对于SSR而言,每个方法都是空的

const dom = {
    // ... 真实的DOM操作接口
}
const ssr = {
  insert(child: Text | Element, parent: Element, anchor?: Element | null) {
  },
  remove(child: Element) {
  },
  createText(content: string) {
    return null as unknown as Text
  },
  createElement(type: string) {
    return null as unknown as HTMLElement
  },
  setAttribute(dom: Element, prop: string, lastValue: any, nextValue: any) {
  },
  setText(node: Element, text: string) {
  }
}

const isBrowser = typeof window !== 'undefined'

export function getHost() {
  return (!isBrowser || isHydrateProcessing()) ? ssr : dom
}

1.3. typescript类型支持

由于定义的()=>()=>jsx.Element这种组件类型,并不是@types/react中定义的合法的组件类型

function Button() {
  return  () => {
    return (<button>click me</button>)
  }
}

当时写了一篇博客研究这个问题:自定义React中JSX的Element类型

最后的解决办法是重写了一个非常简易的JSX类型声明

declare global {
  namespace JSX {
    type Element = ReactElement | CustomFunctionComponent

    interface ElementClass {
      (prop: any): any
    }

    interface IntrinsicElements {
      [prop: string]: any
    }
  }
}

然后修改react/jsx-runtime

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "paths": {
        "react/jsx-runtime": [
            "./node_modules/@shymean/react-vue-jsx"
        ]
    }
  }
}

虽然解决了一片红色错误,但在重构过程中其实还是有一些类型声明的问题,比如函数组件的key需要手动在组件props声明,不然就会提示属性未定义,这个后面再研究一下。

1.4. react-vue-router

基本上是迁移之前的NeZha-Router,貌似没有啥特别的

1.5. react-vue-store

我之前一直非常反感ReduxVuex的字符串访问action的方式,这也是我为什么习惯使用Webstrom开发的原因(webstrom支持vuex字符串跳转到源码)。Vue3提供了一个更符合组合API、类型更友好的Pinia库,用起来非常方便。于是我也写了一个极简的react-vue-store,真的极简,就几行代码

export function defineStore<Id extends string,
  S extends StateTree,
  G extends _GettersTree<S>,
  A extends _ActionsTree>(option: DefineStoreOptions<Id, S, G, A>) {

  const {state, actions, id, instance} = option

  let store
  return function useStore(_instance: StoreInstance = instance) {
    if (!_instance._s.has(id)) {
      const data = reactive(state ? state() : {})
      store = Object.assign(data, actions)
      _instance._s.set(id, store)
    } else {
      store = _instance._s.get(id)
    }

    return store as Store<Id, S, G, A>
  }
}

SSR与Web应用最大的区别在于,每个用户的访问状态应该是隔离的,因此需要store是独立的,所以defineStore接受一个instance的配置项,表示是哪个实例的状态

export interface StoreInstance {
  _s: Map<string, Store>
}

export function createStoreInstance(): StoreInstance {
  return {
    _s: new Map()
  }
}

使用起来与Pinia基本一致

import {createStoreInstance, defineStore} from '@shymean/react-vue-store'

const instance = createStoreInstance() // 不同store可以复用同一个instance

export const useMainStore = defineStore({
  id: 'test',
  instance,
  state(): { x: number, y: number } {
    return {
      x: 1,
      y: 2
    }
  },
  getters: {
    doubleX(): number {
      return 2 * this.x
    }
  },
  actions: {
    addX() {
      this.x += 1
    }
  },
})

在组件中使用

function App() {
  const store = useMainStore()
  const count = ref(0)

  const add = () => {
    store.addX()
  }

  return () => {
    return (<div>
      <button onClick={add}>click {count.value}</button>
      <SubApp/>
    </div>)
  }
}

2. vite

上一个版本是使用NeZha + Webpack 实现的开发环境和SSR,存在的缺点就是启动慢、热更新慢、打包慢。因此这个版本决定使用Vite。

回想第一次接触vite还是在它刚初始化仓库的时候,一天一个版本,当时还写了一篇博客分析源码:尝鲜Vue3——vite源码分析 ,现在vite都已经快要3.0了,时间过得太快了(当然这篇博客里面分析的源码也已经过时了)

2.1. ssr server

vite基础的开发环境基本上没啥好说的了,主要是vite SSR这里,还是遇到了一些问题。主要参考官方文档

vite SSR 的本质实际上是将vite作为中间件,主要处理文件编译、热更新等逻辑,最后由实际的Node server服务器渲染并返回HTML。

因此整个应用大概是这个样子

const development = process.env.NODE_ENV === 'development'

async function createServer() {
    const app = express()

    const vite = await createViteServer({
        server: {
            middlewareMode: true,
            hmr: development
        },
        appType: 'custom'
    })
    // 使用 vite 的 Connect 实例作为中间件
    app.use(vite.middlewares)


    const port = 3001
    app.listen(port)

    console.log('ssr server listen at', port)

    app.use(express.static(path.resolve(__dirname, "./dist/client/assets")));

    app.use('/*', async (req, res, next) => {
        const url = req.originalUrl
        if (url === '/favicon.ico') return res.end('')

        try {
            const filePath = development ? path.resolve(__dirname, 'index.html') : path.resolve(__dirname, 'dist/client/index.html')
            let template = fs.readFileSync(filePath, 'utf-8')

            let render
            if (development) {
                // 注入热更新等逻辑
                template = await vite.transformIndexHtml(url, template)
                const {render: devRender} = await vite.ssrLoadModule('/src/entry-server.tsx')
                render = devRender
            } else {
                const {default: {render: prodRender}} = await import('./dist/server/entry-server.js')
                render = prodRender
            }

            const {html: appHtml, initData, seoData = {}} = await render(url)

            const html = template
                .replace(`<!--ssr-init-data-->`, initData)
                .replace(`<!--ssr-outlet-->`, appHtml)
                .replace(`<!--ssr-seo-data-->`, seoData)

            res.status(200).set({'Content-Type': 'text/html'}).end(html)
        } catch (e) {
            vite.ssrFixStacktrace(e)
            next(e)
        }
    })

}

可以很明显地看见其中一些区分运行环境的代码

  • 只有开发环境需要热更新
  • 生产环境需要获取打包后的相关资源路径,因此在生产环境下读取的是输出后的html文件作为模板
  • 生产环境不应该处理文件编译等逻辑,因此使用的入口文件是编译后的

render方法用于处理服务端生成HTML的逻辑,根据当前请求的url,找到对应的路由组件,然后执行对应的数据初始化和renderHTML逻辑,此外还需要考虑根据初始化数据来执行SEO数据渲染的相关逻辑

function renderTDK(seo: ITDKData) {
    return `
<title>${seo.title}</title>
<meta name="description" content="${seo.description}">
<meta name="keywords" content="${seo.keywords}">
`
}

export async function render(url: string) {

    const to = getMatchRouteConfig(url, routes)
    let location = createLocation(url, (to && to.path) || '')

    if (!to) {
        return ''
    }

    // store instance
    const instance = createStoreInstance()

    const component = to.component as ServerComponent

    let seoData: ITDKData | undefined

    if (component) {
        if (component.asyncData) {
            await component.asyncData({instance, location})
        }

        if (component.asyncSEO) {
            seoData = await component.asyncSEO({instance, location})
        }
    }

    const store = useArticleStore(instance)

    return {
        html: renderHTML(<App url={url} instance={instance} location={location}/>),
        initData: JSON.stringify(store),
        seoData: renderTDK(seoData)
    }
}

在App中,会通过provide注入当前访问组件的store 实例,在页面组件中,就可以通过inject获取访问实例,这样就规避了react-vue-store中不同访问用户状态共享的问题。

2.2. 打包优化

在处理生产环境的产物时,需要考虑一些性能点

  • node_modules里面的文件一般不会经常变化,而业务代码会经常迭代,需要拆包
  • 博客中使用highlight.js渲染markdown中的代码,这个库体积比较大,采用的是第三方CDN(省钱省流量)
  • 微软都放弃IE了,因此决定这次打包的target使用es module,不再考虑老版本的兼容

对于第1点,通过manualChunks就可以解决

const buildConfig = {
    rollupOptions: {
        output: {
            manualChunks: (id: string) => {
                if (id.includes('node_modules')) {
                    return 'vendor';
                } else {
                    return 'custom';
                }
            }
        }
    },
}

对于第2点,声明external: ['highlight.js']时,rollup就不会把这个包的内容打包到输出文件,但怎么让highlight.js引用到正常的window.hljs

可以配置output.globals'highlight.js': "window.hljs",跟webpack的externals比较相似。

 const buildConfig = {
    // 
    rollupOptions: {
        external: ['highlight.js'],
        output: {
            globals: {
                'highlight.js': "window.hljs"
            },
            manualChunks: (id: string) => {
                if (id.includes('node_modules')) {
                    return 'vendor';
                } else {
                    return 'custom';
                }
            }
        }
    },
}

看起来能解决问题,打包一下却发现了问题,globals的配置并没有生效!

排查发现,globals貌似只能在output.formatiifeumd时才能生效,但是更新format之后,又不能使用manualChunks了。

拆包肯定是很重要的,外部第三方CDN也很重要,二者可以兼得吗?

最后用了个比较hack的办法,使用vite的虚拟模块插件

const highLightPlugin = (isSSR) => {
    const virtualModuleId = 'virtual:highlight.js'
    const resolvedVirtualModuleId = '\0' + virtualModuleId

    return {
        name: 'highlightPlugin',
        resolveId(id) {
            if (id === virtualModuleId) {
                return resolvedVirtualModuleId
            }
        },
        load(id) {
            if (id === resolvedVirtualModuleId) {
                if (!isSSR) {
                    return `const val = window.hljs; export default val`
                } else {
                    return `import highlight from 'highlight.js'; export default highlight`
                }

            }
        }
    }
}

然后将代码里面依赖highlight.js的地方替换成virtual:highlight.js,即

// import highlight from 'highlight.js'
import highlight from 'virtual:highlight.js'

完美解决~

最后的es打包结果还是比较满意的,比上一个NeZha的版本体积小了不少。

2.3. 内存泄漏

由于react-vue中使用了部分全局变量,如

  • @vue/reactivity中的targetMap收集对象每个被依赖属性的effect
  • scheduler中的queuepostQueue,收集渲染中过程中的任务

在服务端渲染HTML如果没有考虑到这些地方的释放,就容易发生内存泄漏

部署之后,通过docker stats观察了一段时间,发现内存占用从

变成了

这会导致服务运行一段时间后,内存就会被打爆。

短时间内重构react-vue的底层可能不太靠谱,先临时通过pauseTrackingresetTracking等方法在SSR渲染阶段关闭依赖跟踪、清空全局任务等定期删除数据。

后面需要重写renderHTML这个方法。

3. 项目结构与部署

可以看做是之前Docker部署monorepo项目这篇文章的实践。

本来计划了很久的使用Docker部署,一直没有行动起来。这次终于一起搞定了。

下次换服务器的时候,就不用再重新折腾一遍MySQL、Node环境了。

4. 小结

感觉拖延症越来越严重了,本来计划两周时间就可以切换到新版本的,结果拖了两三个月~

虽然目前已经上线了,实际上也有一些TODO还没有完成,比如计划使用css module重构整个样式管理,现在也只有推迟到下一个版本了。

另外还有一些BUG、比如SSR内存泄漏、组件key提示错误以及其他一些未发现的问题,后面再慢慢修复吧,就这样。